From 5901fd734f4faf30e725073c4612059de73d07bd Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:32:46 +0100 Subject: [PATCH 01/47] session: server authentication core, static tokens, failure throttle (P3-T002 PR-14a part 1) The auth module of design P3-T002 (owner-approved) and ADR 0046: Authenticator trait and ServerAuth; Role (viewer < operator < admin); Credentials, Authenticated and AuthFailure with Spark's two UNAUTHENTICATED messages. Static tokens krishiv.auth.tokens..{secret,principal,kind,role} are held as SHA-256 digests compared in constant time against every entry: at least 32 bytes, no shared secrets, env:/file: references only, and a file readable by group or others is refused. spark.connect.authenticate.token is one admin token named spark, with a startup warning. Failures are throttled per peer address (krishiv.auth.failureThrottle.*) and each one writes one krishiv::auth record and one (surface, method) counter. Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- Cargo.lock | 1 + crates/krishiv-session/CLAUDE.md | 1 + crates/krishiv-session/Cargo.toml | 2 + crates/krishiv-session/src/auth/config.rs | 241 ++++++++++ crates/krishiv-session/src/auth/mod.rs | 289 ++++++++++++ crates/krishiv-session/src/auth/secret_ref.rs | 126 ++++++ crates/krishiv-session/src/auth/server.rs | 152 +++++++ crates/krishiv-session/src/auth/tests.rs | 422 ++++++++++++++++++ crates/krishiv-session/src/auth/throttle.rs | 113 +++++ crates/krishiv-session/src/auth/tokens.rs | 130 ++++++ crates/krishiv-session/src/lib.rs | 1 + 11 files changed, 1478 insertions(+) create mode 100644 crates/krishiv-session/src/auth/config.rs create mode 100644 crates/krishiv-session/src/auth/mod.rs create mode 100644 crates/krishiv-session/src/auth/secret_ref.rs create mode 100644 crates/krishiv-session/src/auth/server.rs create mode 100644 crates/krishiv-session/src/auth/tests.rs create mode 100644 crates/krishiv-session/src/auth/throttle.rs create mode 100644 crates/krishiv-session/src/auth/tokens.rs diff --git a/Cargo.lock b/Cargo.lock index eeb3e8c4f..037386725 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2477,6 +2477,7 @@ dependencies = [ "prost", "reqwest", "serde_json", + "sha2", "tempfile", "thiserror", "tokio", diff --git a/crates/krishiv-session/CLAUDE.md b/crates/krishiv-session/CLAUDE.md index 59774fbfa..178968e7f 100644 --- a/crates/krishiv-session/CLAUDE.md +++ b/crates/krishiv-session/CLAUDE.md @@ -24,6 +24,7 @@ Paths are relative to this crate. - `src/query_fold.rs` the bookkeeping around one running query: `QueryStart` (counters read at start), `QueryFold`/`MetricStream`/`PendingFold` (the one fold of its metrics into the engine series and `system.queries` when the stream ends, fails or is dropped; holds the admission permit), `QueuedRow` (the history row of a query waiting for admission) - `src/admission.rs` the admission seam (E5): `AdmissionController::admit(&QueryRequest) -> Permit` asked once per query in `run_on_query_logged`; default `Unlimited`; a waiting `admit` shows the query `queued` in `system.queries`, a refusal closes the row `failed`, a drop while queued `cancelled`; the permit (slot, optional per-query memory budget) lives in the query's `QueryFold` and releases on drop; `tests/admission.rs` - `src/access.rs` `StatementInfo` / `Session::statement_info` (class + read securables with columns), write/DDL checks (`authorize_statement`), the `krishiv::audit` denial record, `PathPrefixPolicy` +- `src/auth/` (P3-T002, design `design/P3-T002-authentication.md`, ADR 0046) server authentication: `Role` (viewer < operator < admin), `Credentials` / `Authenticated` / `AuthFailure` (Spark's two `UNAUTHENTICATED` messages), the `Authenticator` trait and `ServerAuth` (throttle, then static tokens; one `krishiv::auth` warn record and one `(surface, method)` counter per failure, never the credential); `config.rs` `AuthConfig::from_settings` (`krishiv.auth.tokens..{secret,principal,kind,role}`, `spark.connect.authenticate.token` = one admin token named `spark`, `krishiv.auth.failureThrottle.*`; unknown `krishiv.auth.*` keys refused); `secret_ref.rs` `env:`/`file:` references (a file readable by group or others refuses startup); `tokens.rs` SHA-256 digests compared in constant time against every entry (secrets >= 32 bytes, no two tokens share one); `throttle.rs` per-address failure blocks, bounded at 65,536 addresses - `src/partitions.rs` partitioned and bucketed tables: the layout `CREATE TABLE` stores (partition columns last), the partitions the catalog tracks (`ADD PARTITION`, `SET LOCATION` and static `INSERT` values cast to the column types, `DROP PARTITION` of a managed table: log first, then the directories; each partitioned table has a read/write lock (`InMemoryCatalogStore::partition_ddl` / `partition_write`, keyed by location, weak entries): `RENAME`/`DROP PARTITION`, `MSCK REPAIR` and `TRUNCATE` hold it exclusively (across their log commit retries) and re-read the entry under it, an `INSERT` holds it shared from its write to its partition registration; external tables refused, registered after `INSERT`/CTAS), `DESCRIBE ... PARTITION`, `SHOW PARTITIONS`, `ANALYZE ... PARTITION`; `tests/partitioned_tables.rs` - `src/column_stats.rs` `ANALYZE TABLE .. FOR COLUMNS` (`CommandUtils.statExprs`): one aggregate over the table, kept in `CatalogTable::column_stats` (the catalog is in memory, no on-disk format), printed by `DESCRIBE EXTENDED t col`; a write, `TRUNCATE`, `ADD PARTITION`, `DROP PARTITION` or `SET LOCATION` drops them (`updateTableStats`); published only to the entry the aggregate read (`CatalogTable::incarnation`, set by the store at create, kept by `replace_table`), so a table dropped and created again meanwhile gets none, nor one whose data changed after it was read (`CatalogTable::data_version`, moved by `data_changed`, which every stats-clearing change calls); `MSCK REPAIR` keeps them, as Spark 4.2.0 does; `tests/char_varchar_write.rs` - `src/table_commands/files.rs` a managed table's directory: `authorize_path` (the policy per privilege), `deletable_dir` (inside the root it was created under, no links, `MODIFY`), `delete_table_files`, `remove_dir` diff --git a/crates/krishiv-session/Cargo.toml b/crates/krishiv-session/Cargo.toml index c0f9c0a70..03dc0c028 100644 --- a/crates/krishiv-session/Cargo.toml +++ b/crates/krishiv-session/Cargo.toml @@ -29,6 +29,8 @@ krishiv-types = { path = "../krishiv-types", version = "=0.2.0" } krishiv-udf-sdk = { path = "../krishiv-udf-sdk", version = "=0.2.0" } # The `EXPLAIN VERBOSE` JSON document. serde_json = { workspace = true, features = ["std"] } +# Static token digests (`auth/tokens.rs`, P3-T002). +sha2.workspace = true # Row hashes for the agent table (`xxhash3_128`, v1 frozen in `agent.rs`). twox-hash = { workspace = true, features = ["xxhash3_128"] } # Warnings from analysis and planning are logged, as Spark logs them. diff --git a/crates/krishiv-session/src/auth/config.rs b/crates/krishiv-session/src/auth/config.rs new file mode 100644 index 000000000..670c004de --- /dev/null +++ b/crates/krishiv-session/src/auth/config.rs @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: Apache-2.0 +//! The `krishiv.auth.*` settings (design §3.2, §3.1 throttling). +//! +//! Static tokens are named, so their keys are a pattern the fixed registry cannot hold: +//! +//! ```text +//! krishiv.auth.tokens..secret = env:NAME | file:/path (ADR 0038 reference; required) +//! krishiv.auth.tokens..principal = prometheus (required) +//! krishiv.auth.tokens..kind = user | service (default user) +//! krishiv.auth.tokens..role = viewer | operator | admin (required) +//! spark.connect.authenticate.token = | env:.. | file:.. (Spark's key: one admin token) +//! krishiv.auth.sparkTokenPrincipal = spark (that token's principal) +//! krishiv.auth.failureThrottle.maxFailures = 20 (per peer address and window) +//! krishiv.auth.failureThrottle.window = 60s +//! krishiv.auth.failureThrottle.blockFor = 60s +//! ``` +//! +//! Every key is startup-only. An unknown `krishiv.auth.*` key is refused, so a misspelt +//! setting never leaves a server open. + +use std::collections::BTreeMap; +use std::time::Duration; + +use krishiv_common::error::KrishivError; +use krishiv_common::principal::PrincipalKind; + +use super::Role; +use super::secret_ref::{SecretRef, invalid}; + +/// The key prefix of every Krishiv authentication setting. +const PREFIX: &str = "krishiv.auth."; +/// Spark's pre-shared token key (`SparkConnectService`, Spark 4.3.0). +pub(crate) const SPARK_TOKEN_KEY: &str = "spark.connect.authenticate.token"; +/// The token name the Spark key defines. +pub(crate) const SPARK_TOKEN_NAME: &str = "spark"; + +/// Whether `key` is an authentication setting [`AuthConfig::from_settings`] reads. +#[must_use] +pub fn is_auth_key(key: &str) -> bool { + key.starts_with(PREFIX) || key == SPARK_TOKEN_KEY +} + +/// One configured static token. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct TokenSpec { + /// The name in `krishiv.auth.tokens.`, or `spark` for Spark's key. + pub name: String, + /// Where the secret comes from. + pub secret: SecretRef, + /// The principal's name. + pub principal: String, + /// User or service principal. + pub kind: PrincipalKind, + /// The role. + pub role: Role, +} + +/// Failure throttling (threat model T-A4). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct ThrottleConfig { + /// Failures from one peer address within `window` that start a block; 0 turns it off. + pub max_failures: u32, + /// The counting window. + pub window: Duration, + /// How long a blocked peer is refused. + pub block_for: Duration, +} + +impl Default for ThrottleConfig { + fn default() -> Self { + Self { + max_failures: 20, + window: Duration::from_mins(1), + block_for: Duration::from_mins(1), + } + } +} + +/// The authentication settings of a server. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct AuthConfig { + /// The static tokens, by name. + pub tokens: Vec, + /// Failure throttling. + pub throttle: ThrottleConfig, +} + +/// The fields of one `krishiv.auth.tokens.` group as given. +#[derive(Default)] +struct TokenFields { + secret: Option, + principal: Option, + kind: Option, + role: Option, +} + +impl AuthConfig { + /// Reads every authentication setting in `settings` (other keys are ignored; see + /// [`is_auth_key`]). A key given twice keeps its last value. + /// + /// # Errors + /// `KRISHIV_INVALID_CONFIG_VALUE` for an unknown `krishiv.auth.*` key, a bad value, a + /// token without a secret, principal or role, or a secret that is not a reference. + pub fn from_settings<'a>( + settings: impl IntoIterator, + ) -> Result { + let mut groups: BTreeMap = BTreeMap::new(); + let mut spark: Option = None; + let mut spark_principal = SPARK_TOKEN_NAME.to_owned(); + let mut throttle = ThrottleConfig::default(); + for (key, raw) in settings { + if key == SPARK_TOKEN_KEY { + spark = Some(SecretRef::parse(key, raw, true)?); + continue; + } + let Some(rest) = key.strip_prefix(PREFIX) else { + continue; + }; + if let Some(token) = rest.strip_prefix("tokens.") { + let Some((name, field)) = token.rsplit_once('.') else { + return Err(unknown(key)); + }; + if name.is_empty() || name.contains('.') { + return Err(invalid(key, name, "a token name is one non-empty segment")); + } + let entry = groups.entry(name.to_owned()).or_default(); + match field { + "secret" => entry.secret = Some(SecretRef::parse(key, raw, false)?), + "principal" => entry.principal = Some(non_empty(key, raw)?), + "kind" => entry.kind = Some(parse_kind(key, raw)?), + "role" => entry.role = Some(parse_role(key, raw)?), + _ => return Err(unknown(key)), + } + continue; + } + match rest { + "sparkTokenPrincipal" => spark_principal = non_empty(key, raw)?, + "failureThrottle.maxFailures" => { + throttle.max_failures = raw + .trim() + .parse() + .map_err(|_| invalid(key, raw, "expected a whole number >= 0"))?; + } + "failureThrottle.window" => throttle.window = parse_duration(key, raw)?, + "failureThrottle.blockFor" => throttle.block_for = parse_duration(key, raw)?, + _ => return Err(unknown(key)), + } + } + let mut tokens = Vec::with_capacity(groups.len() + usize::from(spark.is_some())); + for (name, f) in groups { + if name == SPARK_TOKEN_NAME && spark.is_some() { + return Err(invalid( + &format!("{PREFIX}tokens.{name}"), + &name, + "the name `spark` is taken by spark.connect.authenticate.token", + )); + } + let missing = |field: &str| { + invalid( + &format!("{PREFIX}tokens.{name}.{field}"), + "", + "required for every configured token", + ) + }; + tokens.push(TokenSpec { + secret: f.secret.ok_or_else(|| missing("secret"))?, + principal: f.principal.ok_or_else(|| missing("principal"))?, + kind: f.kind.unwrap_or(PrincipalKind::User), + role: f.role.ok_or_else(|| missing("role"))?, + name, + }); + } + if let Some(secret) = spark { + // Spark's token is all-powerful; the owner kept that (design §6 question 4). + tokens.push(TokenSpec { + name: SPARK_TOKEN_NAME.to_owned(), + secret, + principal: spark_principal, + kind: PrincipalKind::User, + role: Role::Admin, + }); + } + Ok(Self { tokens, throttle }) + } + + /// Whether any authentication method is configured. + #[must_use] + pub fn is_configured(&self) -> bool { + !self.tokens.is_empty() + } +} + +fn unknown(key: &str) -> KrishivError { + invalid(key, key, "not a known authentication setting") +} + +fn non_empty(key: &str, raw: &str) -> Result { + let v = raw.trim(); + if v.is_empty() { + return Err(invalid(key, raw, "must not be empty")); + } + Ok(v.to_owned()) +} + +fn parse_role(key: &str, raw: &str) -> Result { + Role::parse(raw.trim()).ok_or_else(|| invalid(key, raw, "expected viewer, operator or admin")) +} + +fn parse_kind(key: &str, raw: &str) -> Result { + match raw.trim().to_ascii_lowercase().as_str() { + "user" => Ok(PrincipalKind::User), + "service" => Ok(PrincipalKind::Service), + _ => Err(invalid(key, raw, "expected user or service")), + } +} + +fn parse_duration(key: &str, raw: &str) -> Result { + let s = raw.trim(); + let split = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()); + let (digits, unit) = s.split_at(split); + let n: u64 = digits + .parse() + .map_err(|_| invalid(key, raw, "expected a duration such as 500ms, 60s, 5m or 1h"))?; + let d = match unit.trim() { + "ms" => Duration::from_millis(n), + "s" | "" => Duration::from_secs(n), + "m" | "min" => Duration::from_secs(n.saturating_mul(60)), + "h" => Duration::from_secs(n.saturating_mul(3600)), + _ => { + return Err(invalid( + key, + raw, + "expected a duration such as 500ms, 60s, 5m or 1h", + )); + } + }; + Ok(d) +} diff --git a/crates/krishiv-session/src/auth/mod.rs b/crates/krishiv-session/src/auth/mod.rs new file mode 100644 index 000000000..7a4b612ff --- /dev/null +++ b/crates/krishiv-session/src/auth/mod.rs @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Server authentication (P3-T002; design `design/P3-T002-authentication.md`, ADR 0046). +//! +//! One [`Authenticator`] turns what a transport received (a bearer token today; client +//! certificates in a later unit) into an [`Authenticated`] principal with a [`Role`]. The Spark +//! Connect listener, the admin HTTP server, `/metrics` and the UI all call the same +//! [`ServerAuth`]; none keeps a token of its own. +//! +//! What a role may do is not decided here: that is ADR 0046's matrix, applied by the session's +//! access policy. +//! +//! - [`AuthConfig`] reads the `krishiv.auth.*` settings (and Spark's +//! `spark.connect.authenticate.token`). +//! - [`StaticTokens`]: one principal per configured token, held as SHA-256 digests and compared +//! in constant time against every entry. +//! - [`FailureThrottle`]: repeated failures from one peer address are refused for a while. +//! - [`ServerAuth`]: the chain, its failure log record (target `krishiv::auth`, never the +//! credential) and its failure counters. + +mod config; +mod secret_ref; +mod server; +mod throttle; +mod tokens; + +#[cfg(test)] +mod tests; + +use std::fmt; +use std::net::SocketAddr; + +use futures::future::BoxFuture; +use krishiv_common::principal::Principal; + +pub use config::{AuthConfig, ThrottleConfig, TokenSpec, is_auth_key}; +pub use secret_ref::SecretRef; +pub use server::{FailureCount, ServerAuth}; +pub use throttle::FailureThrottle; +pub use tokens::StaticTokens; + +/// A server role (ADR 0046 §1): a total order, so a check is `role >= required`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Role { + /// Sees metadata and its own work. + Viewer, + /// Reads data, runs client code, acts on others' running work. + Operator, + /// Everything, including durable writes, DDL and cluster changes. + Admin, +} + +impl Role { + /// The configuration spelling: `viewer`, `operator` or `admin`. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Viewer => "viewer", + Self::Operator => "operator", + Self::Admin => "admin", + } + } + + /// Parses the configuration spelling, ignoring ASCII case. + #[must_use] + pub fn parse(s: &str) -> Option { + [Self::Viewer, Self::Operator, Self::Admin] + .into_iter() + .find(|r| r.as_str().eq_ignore_ascii_case(s)) + } +} + +impl fmt::Display for Role { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// The listener a request arrived on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum Surface { + /// The Spark Connect gRPC listener. + Connect, + /// The admin HTTP API. + Admin, + /// `GET /metrics`. + Metrics, + /// The web UI and its `/api/internal` routes. + Ui, +} + +impl Surface { + /// Every surface, in counter order. + pub const ALL: [Self; 4] = [Self::Connect, Self::Admin, Self::Metrics, Self::Ui]; + + /// The label value: `connect`, `admin`, `metrics` or `ui`. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Connect => "connect", + Self::Admin => "admin", + Self::Metrics => "metrics", + Self::Ui => "ui", + } + } +} + +/// How a request authenticated (or tried to). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum Method { + /// A configured static token. + Token, + /// An OIDC access token (a later unit). + Oidc, + /// A client certificate (a later unit). + Mtls, + /// No credential was presented. + None, +} + +impl Method { + /// Every method, in counter order. + pub const ALL: [Self; 4] = [Self::Token, Self::Oidc, Self::Mtls, Self::None]; + + /// The label value: `token`, `oidc`, `mtls` or `none`. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Token => "token", + Self::Oidc => "oidc", + Self::Mtls => "mtls", + Self::None => "none", + } + } +} + +/// What a transport received, borrowed for one authentication. Never logged. +#[derive(Clone, Copy)] +#[non_exhaustive] +pub struct Credentials<'a> { + /// The token of an `authorization: Bearer ` header or gRPC metadata entry. + pub bearer: Option<&'a str>, + /// The peer's address (the throttle's key). + pub peer: SocketAddr, + /// The listener. + pub surface: Surface, +} + +impl<'a> Credentials<'a> { + /// Credentials with no bearer token. + #[must_use] + pub fn new(peer: SocketAddr, surface: Surface) -> Self { + Self { + bearer: None, + peer, + surface, + } + } + + /// The same credentials carrying `token`. + #[must_use] + pub fn with_bearer(mut self, token: &'a str) -> Self { + self.bearer = Some(token); + self + } + + /// The same credentials with the token of an `Authorization` header value, if it is a + /// `Bearer` one (scheme compared ignoring ASCII case, as RFC 9110 §11.1 asks). + #[must_use] + pub fn with_authorization_header(mut self, value: &'a str) -> Self { + self.bearer = bearer_token(value); + self + } +} + +impl fmt::Debug for Credentials<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Credentials") + .field( + "bearer", + &self.bearer.map(|_| krishiv_common::secret::REDACTED), + ) + .field("peer", &self.peer) + .field("surface", &self.surface) + .finish() + } +} + +/// The token of an `Authorization` header value `Bearer `; `None` for any other scheme +/// or an empty token. +#[must_use] +pub fn bearer_token(value: &str) -> Option<&str> { + let (scheme, token) = value.split_once(' ')?; + let token = token.trim_start_matches(' '); + (scheme.eq_ignore_ascii_case("Bearer") && !token.is_empty()).then_some(token) +} + +/// A successful authentication. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct Authenticated { + /// Who the request acts for. + pub principal: Principal, + /// The role; `None` when the credential maps to no role (every request is then refused by + /// the policy, ADR 0046 §2). + pub role: Option, + /// How it authenticated. + pub method: Method, +} + +impl Authenticated { + /// An authentication result. + #[must_use] + pub fn new(principal: Principal, role: Option, method: Method) -> Self { + Self { + principal, + role, + method, + } + } +} + +/// Why authentication failed. Never carries the credential. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum AuthFailure { + /// No credential where one is required. + Missing, + /// A credential that matches nothing. + Invalid, + /// A credential that has expired. + Expired, + /// The credential cannot be checked now (for example no OIDC key could be fetched): clients + /// should retry. + Unavailable, + /// The peer failed too often and is refused for a while. + Throttled, +} + +impl AuthFailure { + /// The label value and log field. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Missing => "missing", + Self::Invalid => "invalid", + Self::Expired => "expired", + Self::Unavailable => "unavailable", + Self::Throttled => "throttled", + } + } + + /// The status message: Spark's own two for a missing and a wrong token + /// (`PreSharedKeyAuthenticationInterceptor`, Spark 4.3.0). + #[must_use] + pub fn message(self) -> &'static str { + match self { + Self::Missing => "No authentication token provided", + Self::Invalid => "Invalid authentication token", + Self::Expired => "Expired authentication token", + Self::Unavailable => "Authentication is temporarily unavailable", + Self::Throttled => "Too many failed authentication attempts", + } + } + + /// Whether the transport answers "retry later" (gRPC `UNAVAILABLE`, HTTP 503) rather than + /// "unauthenticated" (gRPC `UNAUTHENTICATED`, HTTP 401). + #[must_use] + pub fn is_retryable(self) -> bool { + matches!(self, Self::Unavailable | Self::Throttled) + } +} + +impl fmt::Display for AuthFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.message()) + } +} + +/// Turns credentials into a principal. One implementation per server ([`ServerAuth`]); tests +/// may supply their own. +pub trait Authenticator: Send + Sync { + /// Authenticates `credentials`. Asynchronous because an OIDC key may need fetching. + fn authenticate<'a>( + &'a self, + credentials: &'a Credentials<'a>, + ) -> BoxFuture<'a, Result>; +} diff --git a/crates/krishiv-session/src/auth/secret_ref.rs b/crates/krishiv-session/src/auth/secret_ref.rs new file mode 100644 index 000000000..2acfb7f4b --- /dev/null +++ b/crates/krishiv-session/src/auth/secret_ref.rs @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Secret references in server settings (ADR 0038): `env:NAME` or `file:/path`, never a value +//! in a configuration dump. A `file:` secret readable by group or others refuses startup +//! (threat model T-A3). + +use std::path::PathBuf; + +use krishiv_common::error::{KrishivError, KrishivInvalidConfigValue}; +use krishiv_common::secret::SecretString; + +/// Where a secret comes from. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum SecretRef { + /// `env:NAME`: the environment variable `NAME`. + Env(String), + /// `file:/path`: the file's contents, one trailing line break removed. + File(PathBuf), + /// A value given inline. Only Spark's `spark.connect.authenticate.token` takes one, so a + /// Spark configuration works unchanged. + Inline(SecretString), +} + +impl SecretRef { + /// Parses the setting `key = raw`. Without `allow_inline` a value that is not a reference + /// is refused (its text is never echoed). + /// + /// # Errors + /// `KRISHIV_INVALID_CONFIG_VALUE` for an empty reference or an inline value where none is + /// allowed. + pub fn parse(key: &str, raw: &str, allow_inline: bool) -> Result { + if let Some(name) = raw.strip_prefix("env:") { + if name.is_empty() { + return Err(invalid( + key, + "env:", + "an environment variable name is required", + )); + } + return Ok(Self::Env(name.to_owned())); + } + if let Some(path) = raw.strip_prefix("file:") { + if path.is_empty() { + return Err(invalid(key, "file:", "a file path is required")); + } + return Ok(Self::File(PathBuf::from(path))); + } + if allow_inline { + return Ok(Self::Inline(SecretString::new(raw))); + } + Err(invalid( + key, + krishiv_common::secret::REDACTED, + "a secret must be a reference, env:NAME or file:/path (ADR 0038)", + )) + } + + /// The secret. + /// + /// # Errors + /// `KRISHIV_INVALID_CONFIG_VALUE` when the variable is unset, the file cannot be read, or + /// the file is readable by group or others. + pub fn resolve(&self, key: &str) -> Result { + match self { + Self::Env(name) => std::env::var(name).map(SecretString::new).map_err(|_| { + invalid( + key, + &format!("env:{name}"), + "the environment variable is not set", + ) + }), + Self::File(path) => { + let shown = format!("file:{}", path.display()); + check_private(key, &shown, path)?; + let mut text = std::fs::read_to_string(path) + .map_err(|e| invalid(key, &shown, &format!("cannot be read: {}", e.kind())))?; + if text.ends_with('\n') { + text.pop(); + if text.ends_with('\r') { + text.pop(); + } + } + Ok(SecretString::new(text)) + } + Self::Inline(secret) => Ok(secret.clone()), + } + } + + /// Whether this is an inline value. + #[must_use] + pub fn is_inline(&self) -> bool { + matches!(self, Self::Inline(_)) + } +} + +/// Refuses a secret file that group or others may read. +#[cfg(unix)] +fn check_private(key: &str, shown: &str, path: &std::path::Path) -> Result<(), KrishivError> { + use std::os::unix::fs::PermissionsExt; + let meta = std::fs::metadata(path) + .map_err(|e| invalid(key, shown, &format!("cannot be read: {}", e.kind())))?; + if meta.permissions().mode() & 0o077 != 0 { + return Err(invalid( + key, + shown, + "the file is readable by group or others; make it mode 0600 or 0400", + )); + } + Ok(()) +} + +/// Other platforms have no mode bits to check. +#[cfg(not(unix))] +fn check_private(_key: &str, _shown: &str, _path: &std::path::Path) -> Result<(), KrishivError> { + Ok(()) +} + +/// `KRISHIV_INVALID_CONFIG_VALUE` for `key`; `shown` must never be a secret. +pub(crate) fn invalid(key: &str, shown: &str, reason: &str) -> KrishivError { + KrishivInvalidConfigValue { + value: format!("'{shown}'"), + key: format!("\"{key}\""), + reason: reason.to_owned(), + } + .into() +} diff --git a/crates/krishiv-session/src/auth/server.rs b/crates/krishiv-session/src/auth/server.rs new file mode 100644 index 000000000..3927f6aed --- /dev/null +++ b/crates/krishiv-session/src/auth/server.rs @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 +//! [`ServerAuth`], the one authenticator of a server (design §3.1). +//! +//! The chain, in a fixed order: a throttled peer is refused first; then the bearer token goes to +//! the static tokens (OIDC and client certificates join in later units, ahead of it as the +//! design orders them); no credential is [`AuthFailure::Missing`]. Every failure writes one log +//! record (target `krishiv::auth`: surface, method, peer, failure; never the credential) and +//! counts once per `(surface, method)`. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use futures::future::BoxFuture; +use krishiv_common::error::KrishivError; + +use super::config::{AuthConfig, SPARK_TOKEN_NAME}; +use super::throttle::FailureThrottle; +use super::tokens::StaticTokens; +use super::{AuthFailure, Authenticated, Authenticator, Credentials, Method, Surface}; + +/// One failure counter (`krishiv_auth_failed_total{surface, method}`, ADR 0046 §8). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct FailureCount { + /// The listener. + pub surface: Surface, + /// The method tried. + pub method: Method, + /// Failures so far. + pub count: u64, +} + +/// The authenticator a server builds once from its settings. +#[derive(Debug)] +pub struct ServerAuth { + tokens: StaticTokens, + throttle: FailureThrottle, + /// Indexed by `surface * Method::ALL.len() + method`. + failures: [AtomicU64; Surface::ALL.len() * Method::ALL.len()], +} + +impl ServerAuth { + /// Builds the authenticator: resolves and hashes every token. Logs a warning when Spark's + /// all-powerful `spark.connect.authenticate.token` is in use. + /// + /// # Errors + /// `KRISHIV_INVALID_CONFIG_VALUE` from [`StaticTokens::build`]. + pub fn new(config: &AuthConfig) -> Result { + let tokens = StaticTokens::build(&config.tokens)?; + if config.tokens.iter().any(|t| t.name == SPARK_TOKEN_NAME) { + tracing::warn!( + target: "krishiv::auth", + "spark.connect.authenticate.token is set: it is one shared token with the admin \ + role; prefer krishiv.auth.tokens. with one principal and role per token" + ); + } + Ok(Self { + tokens, + throttle: FailureThrottle::new(config.throttle), + failures: std::array::from_fn(|_| AtomicU64::new(0)), + }) + } + + /// Whether any method is configured. A server with none runs with authentication off + /// (loopback only; the listener enforces that). + #[must_use] + pub fn is_enabled(&self) -> bool { + !self.tokens.is_empty() + } + + /// Authenticates `credentials` at `now` (the clock is a parameter for tests). + /// + /// # Errors + /// The [`AuthFailure`], after its log record and counter. + pub fn authenticate_at( + &self, + credentials: &Credentials<'_>, + now: Instant, + ) -> Result { + let ip = credentials.peer.ip(); + if self.throttle.is_blocked(ip, now) { + return Err(self.fail(credentials, Method::None, AuthFailure::Throttled)); + } + let Some(token) = credentials.bearer else { + return Err(self.fail(credentials, Method::None, AuthFailure::Missing)); + }; + match self.tokens.lookup(token) { + Some((principal, role)) => Ok(Authenticated::new( + principal.clone(), + Some(role), + Method::Token, + )), + None => { + if self.throttle.record_failure(ip, now) { + tracing::warn!( + target: "krishiv::auth", + peer = %ip, + "authentication failures from this address are throttled" + ); + } + Err(self.fail(credentials, Method::Token, AuthFailure::Invalid)) + } + } + } + + /// The failure counters, every `(surface, method)` pair in order. + #[must_use] + pub fn failure_counts(&self) -> Vec { + let mut out = Vec::with_capacity(self.failures.len()); + for surface in Surface::ALL { + for method in Method::ALL { + out.push(FailureCount { + surface, + method, + count: self.failures[index(surface, method)].load(Ordering::Relaxed), + }); + } + } + out + } + + /// Logs and counts `failure`, and returns it. + fn fail(&self, c: &Credentials<'_>, method: Method, failure: AuthFailure) -> AuthFailure { + self.failures[index(c.surface, method)].fetch_add(1, Ordering::Relaxed); + tracing::warn!( + target: "krishiv::auth", + surface = c.surface.as_str(), + method = method.as_str(), + peer = %c.peer, + failure = failure.as_str(), + "authentication failed" + ); + failure + } +} + +impl Authenticator for ServerAuth { + fn authenticate<'a>( + &'a self, + credentials: &'a Credentials<'a>, + ) -> BoxFuture<'a, Result> { + let result = self.authenticate_at(credentials, Instant::now()); + Box::pin(std::future::ready(result)) + } +} + +/// The counter slot of `(surface, method)`. +fn index(surface: Surface, method: Method) -> usize { + let s = Surface::ALL.iter().position(|x| *x == surface).unwrap_or(0); + let m = Method::ALL.iter().position(|x| *x == method).unwrap_or(0); + s * Method::ALL.len() + m +} diff --git a/crates/krishiv-session/src/auth/tests.rs b/crates/krishiv-session/src/auth/tests.rs new file mode 100644 index 000000000..778ed295f --- /dev/null +++ b/crates/krishiv-session/src/auth/tests.rs @@ -0,0 +1,422 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Unit tests of the authentication core. + +use std::io::Write as _; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::Path; +use std::time::{Duration, Instant}; + +use krishiv_common::error::{ErrorClass, KrishivError}; +use krishiv_common::principal::PrincipalKind; + +use super::config::SPARK_TOKEN_KEY; +use super::throttle::{MAX_PEERS, step}; +use super::{ + AuthConfig, AuthFailure, Authenticator, Credentials, FailureThrottle, Method, Role, SecretRef, + ServerAuth, StaticTokens, Surface, ThrottleConfig, bearer_token, is_auth_key, +}; + +const TOKEN_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const TOKEN_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +fn peer(last: u8) -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, last)), 40_000) +} + +/// A secret file with `contents` and `mode`. +fn secret_file(dir: &Path, name: &str, contents: &str, mode: u32) -> String { + let path = dir.join(name); + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(contents.as_bytes()).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap(); + } + #[cfg(not(unix))] + let _ = mode; + format!("file:{}", path.display()) +} + +fn config(pairs: &[(&str, &str)]) -> Result { + AuthConfig::from_settings(pairs.iter().copied()) +} + +fn invalid_config(e: &KrishivError) -> bool { + e.error_class() == ErrorClass::KrishivInvalidConfigValue +} + +/// Two tokens, `a` (operator, user) and `prom` (viewer, service), from 0600 files. +fn two_tokens(dir: &Path) -> AuthConfig { + let a = secret_file(dir, "a", &format!("{TOKEN_A}\n"), 0o600); + let b = secret_file(dir, "b", TOKEN_B, 0o400); + config(&[ + ("krishiv.auth.tokens.a.secret", &a), + ("krishiv.auth.tokens.a.principal", "alice"), + ("krishiv.auth.tokens.a.role", "operator"), + ("krishiv.auth.tokens.prom.secret", &b), + ("krishiv.auth.tokens.prom.principal", "prometheus"), + ("krishiv.auth.tokens.prom.kind", "service"), + ("krishiv.auth.tokens.prom.role", "Viewer"), + ]) + .unwrap() +} + +#[test] +fn roles_are_ordered_and_parse_ignoring_case() { + assert!(Role::Viewer < Role::Operator && Role::Operator < Role::Admin); + assert_eq!(Role::parse("ADMIN"), Some(Role::Admin)); + assert_eq!(Role::parse("root"), None); +} + +#[test] +fn bearer_header_parsing() { + assert_eq!(bearer_token("Bearer abc"), Some("abc")); + assert_eq!(bearer_token("bearer abc"), Some("abc")); + assert_eq!(bearer_token("Basic abc"), None); + assert_eq!(bearer_token("Bearer "), None); + assert_eq!(bearer_token("Bearer"), None); +} + +#[test] +fn credentials_debug_hides_the_token() { + let c = Credentials::new(peer(1), Surface::Connect).with_bearer(TOKEN_A); + let shown = format!("{c:?}"); + assert!(!shown.contains(TOKEN_A), "{shown}"); +} + +#[test] +fn auth_keys_are_recognised() { + assert!(is_auth_key("krishiv.auth.tokens.a.role")); + assert!(is_auth_key(SPARK_TOKEN_KEY)); + assert!(!is_auth_key("krishiv.connect.port")); +} + +#[test] +fn config_reads_named_tokens() { + let dir = tempfile::tempdir().unwrap(); + let c = two_tokens(dir.path()); + assert_eq!(c.tokens.len(), 2); + let prom = c.tokens.iter().find(|t| t.name == "prom").unwrap(); + assert_eq!(prom.role, Role::Viewer); + assert_eq!(prom.kind, PrincipalKind::Service); + let a = c.tokens.iter().find(|t| t.name == "a").unwrap(); + assert_eq!(a.kind, PrincipalKind::User); + assert!(c.is_configured()); +} + +#[test] +fn config_refusals() { + // Unknown keys never pass silently. + for key in [ + "krishiv.auth.tokens.a.colour", + "krishiv.auth.mode", + "krishiv.auth.tokens.nofield", + ] { + let e = config(&[(key, "x")]).unwrap_err(); + assert!(invalid_config(&e), "{key}: {e}"); + } + // A secret that is not a reference is refused, and its text is not echoed. + let e = config(&[("krishiv.auth.tokens.a.secret", TOKEN_A)]).unwrap_err(); + assert!( + invalid_config(&e) && !e.to_string().contains(TOKEN_A), + "{e}" + ); + // Every field but kind is required. + let e = config(&[ + ("krishiv.auth.tokens.a.secret", "env:X"), + ("krishiv.auth.tokens.a.principal", "alice"), + ]) + .unwrap_err(); + assert!(e.to_string().contains("krishiv.auth.tokens.a.role"), "{e}"); + let e = config(&[("krishiv.auth.tokens.a.role", "superuser")]).unwrap_err(); + assert!(invalid_config(&e), "{e}"); + let e = config(&[("krishiv.auth.tokens.a.b.secret", "env:X")]).unwrap_err(); + assert!(invalid_config(&e), "{e}"); +} + +#[test] +fn spark_key_is_one_admin_token() { + let c = config(&[(SPARK_TOKEN_KEY, TOKEN_A)]).unwrap(); + assert_eq!(c.tokens.len(), 1); + let t = &c.tokens[0]; + assert_eq!( + (t.name.as_str(), t.principal.as_str(), t.role), + ("spark", "spark", Role::Admin) + ); + assert!(t.secret.is_inline()); + let c = config(&[ + (SPARK_TOKEN_KEY, "env:SPARK_TOKEN"), + ("krishiv.auth.sparkTokenPrincipal", "ops"), + ]) + .unwrap(); + assert_eq!(c.tokens[0].principal, "ops"); + assert_eq!(c.tokens[0].secret, SecretRef::Env("SPARK_TOKEN".to_owned())); + let e = config(&[ + (SPARK_TOKEN_KEY, TOKEN_A), + ("krishiv.auth.tokens.spark.secret", "env:X"), + ("krishiv.auth.tokens.spark.principal", "x"), + ("krishiv.auth.tokens.spark.role", "viewer"), + ]) + .unwrap_err(); + assert!(invalid_config(&e), "{e}"); +} + +#[test] +fn throttle_settings_parse() { + let c = config(&[ + ("krishiv.auth.failureThrottle.maxFailures", "5"), + ("krishiv.auth.failureThrottle.window", "2m"), + ("krishiv.auth.failureThrottle.blockFor", "500ms"), + ]) + .unwrap(); + assert_eq!(c.throttle.max_failures, 5); + assert_eq!(c.throttle.window, Duration::from_mins(2)); + assert_eq!(c.throttle.block_for, Duration::from_millis(500)); + assert!(!c.is_configured()); + for bad in ["soon", "5d", "-1s"] { + let e = config(&[("krishiv.auth.failureThrottle.window", bad)]).unwrap_err(); + assert!(invalid_config(&e), "{bad}: {e}"); + } +} + +#[test] +fn file_secret_drops_one_line_break() { + let dir = tempfile::tempdir().unwrap(); + let r = secret_file(dir.path(), "s", "abc\r\n", 0o600); + let s = SecretRef::parse("k", &r, false) + .unwrap() + .resolve("k") + .unwrap(); + assert_eq!(s.expose_secret(), "abc"); +} + +#[cfg(unix)] +#[test] +fn a_readable_secret_file_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let r = secret_file(dir.path(), "s", TOKEN_A, 0o644); + let e = SecretRef::parse("k", &r, false) + .unwrap() + .resolve("k") + .unwrap_err(); + assert!(e.to_string().contains("readable by group or others"), "{e}"); +} + +#[test] +fn missing_env_secret_is_refused() { + let e = SecretRef::Env("KRISHIV_TEST_UNSET_AUTH_VARIABLE".to_owned()) + .resolve("k") + .unwrap_err(); + assert!(invalid_config(&e), "{e}"); +} + +#[test] +fn tokens_map_to_their_own_principal() { + let dir = tempfile::tempdir().unwrap(); + let tokens = StaticTokens::build(&two_tokens(dir.path()).tokens).unwrap(); + let (p, role) = tokens.lookup(TOKEN_A).unwrap(); + assert_eq!((p.name.as_str(), role), ("alice", Role::Operator)); + let (p, role) = tokens.lookup(TOKEN_B).unwrap(); + assert_eq!( + (p.name.as_str(), p.kind, role), + ("prometheus", PrincipalKind::Service, Role::Viewer) + ); + assert!(tokens.lookup("wrong").is_none()); + assert!(tokens.lookup(&TOKEN_A[1..]).is_none()); +} + +#[test] +fn token_compare_is_constant_time() { + // Structural: a lookup walks every digest whatever matches (timing is not measured). + let dir = tempfile::tempdir().unwrap(); + let tokens = StaticTokens::build(&two_tokens(dir.path()).tokens).unwrap(); + assert_eq!(tokens.comparisons_per_lookup(), tokens.len()); +} + +#[test] +fn short_and_shared_secrets_refuse_startup() { + let dir = tempfile::tempdir().unwrap(); + let short = secret_file(dir.path(), "short", "tooshort", 0o600); + let c = config(&[ + ("krishiv.auth.tokens.a.secret", &short), + ("krishiv.auth.tokens.a.principal", "alice"), + ("krishiv.auth.tokens.a.role", "admin"), + ]) + .unwrap(); + let e = StaticTokens::build(&c.tokens).unwrap_err(); + assert!(e.to_string().contains("at least 32 bytes"), "{e}"); + + let one = secret_file(dir.path(), "one", TOKEN_A, 0o600); + let two = secret_file(dir.path(), "two", &format!("{TOKEN_A}\n"), 0o600); + let c = config(&[ + ("krishiv.auth.tokens.a.secret", &one), + ("krishiv.auth.tokens.a.principal", "alice"), + ("krishiv.auth.tokens.a.role", "admin"), + ("krishiv.auth.tokens.b.secret", &two), + ("krishiv.auth.tokens.b.principal", "bob"), + ("krishiv.auth.tokens.b.role", "viewer"), + ]) + .unwrap(); + let e = StaticTokens::build(&c.tokens).unwrap_err(); + assert!(e.to_string().contains("same secret"), "{e}"); + assert!(!e.to_string().contains(TOKEN_A), "{e}"); +} + +#[test] +fn throttle_blocks_and_releases() { + let cfg = ThrottleConfig { + max_failures: 3, + window: Duration::from_mins(1), + block_for: Duration::from_secs(30), + }; + let t = FailureThrottle::new(cfg); + let ip = peer(1).ip(); + let now = Instant::now(); + assert!(!t.record_failure(ip, now)); + assert!(!t.record_failure(ip, now)); + assert!( + t.record_failure(ip, now), + "the third failure starts the block" + ); + assert!(t.is_blocked(ip, now + Duration::from_secs(29))); + assert!( + !t.is_blocked(peer(2).ip(), now), + "other peers are not affected" + ); + assert!(!t.is_blocked(ip, now + Duration::from_secs(30))); + // A new window starts from zero. + let later = now + step(cfg); + assert!(!t.record_failure(ip, later)); + assert!(!t.is_blocked(ip, later)); +} + +#[test] +fn throttle_window_resets_counts() { + let cfg = ThrottleConfig { + max_failures: 2, + window: Duration::from_secs(10), + block_for: Duration::from_secs(10), + }; + let t = FailureThrottle::new(cfg); + let ip = peer(1).ip(); + let now = Instant::now(); + assert!(!t.record_failure(ip, now)); + assert!(!t.record_failure(ip, now + Duration::from_secs(11))); + assert!(t.record_failure(ip, now + Duration::from_secs(12))); +} + +#[test] +fn throttle_off_and_bounded() { + let off = FailureThrottle::new(ThrottleConfig { + max_failures: 0, + ..ThrottleConfig::default() + }); + let now = Instant::now(); + for _ in 0..100 { + assert!(!off.record_failure(peer(1).ip(), now)); + } + assert!(!off.is_blocked(peer(1).ip(), now)); + + let t = FailureThrottle::new(ThrottleConfig::default()); + for i in 0..=MAX_PEERS { + let ip = IpAddr::V6(std::net::Ipv6Addr::from(u128::try_from(i).unwrap())); + t.record_failure(ip, now); + } + assert_eq!( + t.tracked(), + MAX_PEERS, + "a full table of live entries tracks no more" + ); + // Once the entries are stale, a new address evicts them. + let later = now + step(ThrottleConfig::default()); + t.record_failure(peer(9).ip(), later); + assert_eq!(t.tracked(), 1); +} + +#[test] +fn server_auth_chain_and_counters() { + let dir = tempfile::tempdir().unwrap(); + let auth = ServerAuth::new(&two_tokens(dir.path())).unwrap(); + assert!(auth.is_enabled()); + let now = Instant::now(); + + let ok = auth + .authenticate_at( + &Credentials::new(peer(1), Surface::Metrics).with_bearer(TOKEN_B), + now, + ) + .unwrap(); + assert_eq!(ok.principal.name, "prometheus"); + assert_eq!((ok.role, ok.method), (Some(Role::Viewer), Method::Token)); + + let missing = auth.authenticate_at(&Credentials::new(peer(1), Surface::Connect), now); + assert_eq!(missing, Err(AuthFailure::Missing)); + assert_eq!( + AuthFailure::Missing.message(), + "No authentication token provided" + ); + + let header = format!("Bearer {}", "x".repeat(40)); + let wrong = auth.authenticate_at( + &Credentials::new(peer(1), Surface::Connect).with_authorization_header(&header), + now, + ); + assert_eq!(wrong, Err(AuthFailure::Invalid)); + assert_eq!( + AuthFailure::Invalid.message(), + "Invalid authentication token" + ); + + let count = |s: Surface, m: Method| { + auth.failure_counts() + .into_iter() + .find(|c| c.surface == s && c.method == m) + .map(|c| c.count) + }; + assert_eq!(count(Surface::Connect, Method::None), Some(1)); + assert_eq!(count(Surface::Connect, Method::Token), Some(1)); + assert_eq!(count(Surface::Metrics, Method::Token), Some(0)); + assert_eq!( + auth.failure_counts().len(), + Surface::ALL.len() * Method::ALL.len() + ); +} + +#[test] +fn server_auth_throttles_a_guessing_peer() { + let dir = tempfile::tempdir().unwrap(); + let auth = ServerAuth::new(&two_tokens(dir.path())).unwrap(); + let now = Instant::now(); + let guess = Credentials::new(peer(7), Surface::Connect).with_bearer("guess"); + for _ in 0..ThrottleConfig::default().max_failures { + assert_eq!(auth.authenticate_at(&guess, now), Err(AuthFailure::Invalid)); + } + // Blocked: even the right token is refused, with a retryable failure. + let right = Credentials::new(peer(7), Surface::Connect).with_bearer(TOKEN_A); + let r = auth.authenticate_at(&right, now); + assert_eq!(r, Err(AuthFailure::Throttled)); + assert!(AuthFailure::Throttled.is_retryable() && !AuthFailure::Invalid.is_retryable()); + // Another peer is unaffected; the blocked one recovers. + let other = Credentials::new(peer(8), Surface::Connect).with_bearer(TOKEN_A); + assert!(auth.authenticate_at(&other, now).is_ok()); + assert!( + auth.authenticate_at(&right, now + Duration::from_secs(61)) + .is_ok() + ); +} + +#[test] +fn no_tokens_means_disabled() { + let auth = ServerAuth::new(&AuthConfig::default()).unwrap(); + assert!(!auth.is_enabled()); +} + +#[test] +fn authenticator_trait_object() { + let dir = tempfile::tempdir().unwrap(); + let auth: Box = Box::new(ServerAuth::new(&two_tokens(dir.path())).unwrap()); + let c = Credentials::new(peer(3), Surface::Admin).with_bearer(TOKEN_A); + let r = futures::executor::block_on(auth.authenticate(&c)).unwrap(); + assert_eq!(r.principal.name, "alice"); +} diff --git a/crates/krishiv-session/src/auth/throttle.rs b/crates/krishiv-session/src/auth/throttle.rs new file mode 100644 index 000000000..2f5770890 --- /dev/null +++ b/crates/krishiv-session/src/auth/throttle.rs @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Failure throttling per peer address (design §3.1; threat model T-A4): after +//! `max_failures` failures within `window`, the address is refused for `block_for`. +//! +//! The table is bounded: past [`MAX_PEERS`] addresses, stale entries are dropped first; when +//! every entry is live, a new address is not tracked (throttling is a brute-force brake, not an +//! access control, so it degrades open rather than refusing everyone). + +use std::collections::HashMap; +use std::net::IpAddr; +use std::sync::{Mutex, PoisonError}; +use std::time::Instant; + +use super::config::ThrottleConfig; + +/// Most peer addresses tracked at once. +pub(crate) const MAX_PEERS: usize = 65_536; + +/// One address's state. +#[derive(Debug, Clone, Copy)] +struct Peer { + window_start: Instant, + failures: u32, + blocked_until: Option, +} + +/// Failure counts per peer address. +#[derive(Debug)] +pub struct FailureThrottle { + config: ThrottleConfig, + peers: Mutex>, +} + +impl FailureThrottle { + /// A throttle with `config`; `max_failures = 0` never blocks. + #[must_use] + pub fn new(config: ThrottleConfig) -> Self { + Self { + config, + peers: Mutex::new(HashMap::new()), + } + } + + /// Whether `peer` is blocked at `now`. + #[must_use] + pub fn is_blocked(&self, peer: IpAddr, now: Instant) -> bool { + if self.config.max_failures == 0 { + return false; + } + let peers = self.peers.lock().unwrap_or_else(PoisonError::into_inner); + peers + .get(&peer) + .and_then(|p| p.blocked_until) + .is_some_and(|until| now < until) + } + + /// Records a failure from `peer` at `now`; returns whether this failure started a block. + pub fn record_failure(&self, peer: IpAddr, now: Instant) -> bool { + if self.config.max_failures == 0 { + return false; + } + let mut peers = self.peers.lock().unwrap_or_else(PoisonError::into_inner); + if !peers.contains_key(&peer) && peers.len() >= MAX_PEERS { + let config = self.config; + peers.retain(|_, p| !is_stale(p, now, config)); + if peers.len() >= MAX_PEERS { + return false; + } + } + let p = peers.entry(peer).or_insert(Peer { + window_start: now, + failures: 0, + blocked_until: None, + }); + if p.blocked_until.is_some_and(|until| now >= until) { + p.blocked_until = None; + p.failures = 0; + p.window_start = now; + } + if now.saturating_duration_since(p.window_start) >= self.config.window { + p.window_start = now; + p.failures = 0; + } + p.failures = p.failures.saturating_add(1); + if p.blocked_until.is_none() && p.failures >= self.config.max_failures { + p.blocked_until = Some(now + self.config.block_for); + return true; + } + false + } + + /// Addresses tracked now (tests). + #[cfg(test)] + pub(crate) fn tracked(&self) -> usize { + self.peers + .lock() + .unwrap_or_else(PoisonError::into_inner) + .len() + } +} + +/// Whether `p` neither blocks nor counts toward a block at `now`. +fn is_stale(p: &Peer, now: Instant, config: ThrottleConfig) -> bool { + let blocked = p.blocked_until.is_some_and(|until| now < until); + let counting = now.saturating_duration_since(p.window_start) < config.window; + !blocked && !counting +} + +/// The window a test needs to step past. +#[cfg(test)] +pub(crate) fn step(config: ThrottleConfig) -> std::time::Duration { + config.window.max(config.block_for) + std::time::Duration::from_secs(1) +} diff --git a/crates/krishiv-session/src/auth/tokens.rs b/crates/krishiv-session/src/auth/tokens.rs new file mode 100644 index 000000000..8f232ebf8 --- /dev/null +++ b/crates/krishiv-session/src/auth/tokens.rs @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Static tokens (design §3.2): one token, one principal. +//! +//! Each secret is resolved once at startup, hashed with SHA-256 and dropped (a +//! [`SecretString`] zeroes itself). A presented token is hashed once and compared with every +//! stored digest in constant time, without an early exit: a map keyed by the digest would leak +//! timing on the digest and gains nothing at tens of tokens. + +use krishiv_common::error::KrishivError; +use krishiv_common::principal::Principal; +use krishiv_common::secret::SecretString; +use sha2::{Digest, Sha256}; + +use super::Role; +use super::config::{SPARK_TOKEN_KEY, SPARK_TOKEN_NAME, TokenSpec}; +use super::secret_ref::invalid; + +/// The shortest secret accepted, in bytes (design §3.2; threat model T-A4). +pub(crate) const MIN_SECRET_BYTES: usize = 32; + +/// One stored token. +#[derive(Debug, Clone)] +struct Entry { + digest: [u8; 32], + principal: Principal, + role: Role, +} + +/// The configured static tokens, as digests. +#[derive(Debug, Clone, Default)] +pub struct StaticTokens { + entries: Vec, +} + +impl StaticTokens { + /// Resolves and hashes every token of `specs`. + /// + /// # Errors + /// `KRISHIV_INVALID_CONFIG_VALUE` when a secret cannot be resolved, is shorter than 32 + /// bytes, or is the same as another token's secret. + pub fn build(specs: &[TokenSpec]) -> Result { + let mut entries: Vec<(Entry, &str)> = Vec::with_capacity(specs.len()); + for spec in specs { + let key = secret_key(&spec.name); + let secret: SecretString = spec.secret.resolve(&key)?; + if secret.expose_secret().len() < MIN_SECRET_BYTES { + return Err(invalid( + &key, + krishiv_common::secret::REDACTED, + &format!("a token must be at least {MIN_SECRET_BYTES} bytes"), + )); + } + let digest = digest(secret.expose_secret()); + drop(secret); + if let Some((_, other)) = entries.iter().find(|(e, _)| e.digest == digest) { + return Err(invalid( + &key, + krishiv_common::secret::REDACTED, + &format!("the same secret as token `{other}`; each token names one principal"), + )); + } + let principal = Principal::new(spec.principal.clone(), spec.kind); + entries.push(( + Entry { + digest, + principal, + role: spec.role, + }, + &spec.name, + )); + } + Ok(Self { + entries: entries.into_iter().map(|(e, _)| e).collect(), + }) + } + + /// How many tokens are configured. + #[must_use] + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether no token is configured. + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// The principal and role of `presented`, if it is a configured token. Every entry is + /// compared, whether or not an earlier one matched. + #[must_use] + pub fn lookup(&self, presented: &str) -> Option<(&Principal, Role)> { + let d = digest(presented); + let mut found = None; + for (i, e) in self.entries.iter().enumerate() { + let same = ct_eq(&d, &e.digest); + if same && found.is_none() { + found = Some(i); + } + } + let e = self.entries.get(found?)?; + Some((&e.principal, e.role)) + } + + /// How many digests one lookup compares (the structural constant-time test). + #[cfg(test)] + pub(crate) fn comparisons_per_lookup(&self) -> usize { + self.entries.len() + } +} + +/// The settings key naming the secret of token `name`. +fn secret_key(name: &str) -> String { + if name == SPARK_TOKEN_NAME { + SPARK_TOKEN_KEY.to_owned() + } else { + format!("krishiv.auth.tokens.{name}.secret") + } +} + +/// SHA-256 of `s`. +fn digest(s: &str) -> [u8; 32] { + Sha256::digest(s.as_bytes()).into() +} + +/// Equality of two digests in time independent of where they differ. +fn ct_eq(a: &[u8; 32], b: &[u8; 32]) -> bool { + let diff = a.iter().zip(b).fold(0_u8, |acc, (x, y)| acc | (x ^ y)); + std::hint::black_box(diff) == 0 +} diff --git a/crates/krishiv-session/src/lib.rs b/crates/krishiv-session/src/lib.rs index 50464f37f..465f34efd 100644 --- a/crates/krishiv-session/src/lib.rs +++ b/crates/krishiv-session/src/lib.rs @@ -44,6 +44,7 @@ mod access; mod admission; mod arrow_values; +pub mod auth; mod cache; mod cancel; mod carrier; From 9baf777794b85d2cc6d2d248f759980656f44708 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:35:22 +0100 Subject: [PATCH 02/47] session: public-api snapshot for the auth module Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-session/public-api.txt | 227 +++++++++++++++++++++++++- 1 file changed, 225 insertions(+), 2 deletions(-) diff --git a/crates/krishiv-session/public-api.txt b/crates/krishiv-session/public-api.txt index b46c830aa..27d0a309d 100644 --- a/crates/krishiv-session/public-api.txt +++ b/crates/krishiv-session/public-api.txt @@ -127,6 +127,228 @@ pub fn krishiv_session::agent::render_estimate(u64, u64, u64, &[alloc::string::S pub fn krishiv_session::agent::session_zone(core::option::Option<&str>) -> core::result::Result pub fn krishiv_session::agent::spark_float(f64) -> alloc::string::String pub fn krishiv_session::agent::spark_float32(f32) -> alloc::string::String +pub mod krishiv_session::auth +#[non_exhaustive] pub enum krishiv_session::auth::AuthFailure +pub krishiv_session::auth::AuthFailure::Expired +pub krishiv_session::auth::AuthFailure::Invalid +pub krishiv_session::auth::AuthFailure::Missing +pub krishiv_session::auth::AuthFailure::Throttled +pub krishiv_session::auth::AuthFailure::Unavailable +impl krishiv_session::auth::AuthFailure +pub fn krishiv_session::auth::AuthFailure::as_str(self) -> &'static str +pub fn krishiv_session::auth::AuthFailure::is_retryable(self) -> bool +pub fn krishiv_session::auth::AuthFailure::message(self) -> &'static str +impl core::clone::Clone for krishiv_session::auth::AuthFailure +pub fn krishiv_session::auth::AuthFailure::clone(&self) -> krishiv_session::auth::AuthFailure +impl core::cmp::Eq for krishiv_session::auth::AuthFailure +impl core::cmp::PartialEq for krishiv_session::auth::AuthFailure +pub fn krishiv_session::auth::AuthFailure::eq(&self, &krishiv_session::auth::AuthFailure) -> bool +impl core::fmt::Debug for krishiv_session::auth::AuthFailure +pub fn krishiv_session::auth::AuthFailure::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for krishiv_session::auth::AuthFailure +pub fn krishiv_session::auth::AuthFailure::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::hash::Hash for krishiv_session::auth::AuthFailure +pub fn krishiv_session::auth::AuthFailure::hash<__H: core::hash::Hasher>(&self, &mut __H) +impl core::marker::Copy for krishiv_session::auth::AuthFailure +impl core::marker::StructuralPartialEq for krishiv_session::auth::AuthFailure +#[non_exhaustive] pub enum krishiv_session::auth::Method +pub krishiv_session::auth::Method::Mtls +pub krishiv_session::auth::Method::None +pub krishiv_session::auth::Method::Oidc +pub krishiv_session::auth::Method::Token +impl krishiv_session::auth::Method +pub const krishiv_session::auth::Method::ALL: [Self; 4] +pub fn krishiv_session::auth::Method::as_str(self) -> &'static str +impl core::clone::Clone for krishiv_session::auth::Method +pub fn krishiv_session::auth::Method::clone(&self) -> krishiv_session::auth::Method +impl core::cmp::Eq for krishiv_session::auth::Method +impl core::cmp::PartialEq for krishiv_session::auth::Method +pub fn krishiv_session::auth::Method::eq(&self, &krishiv_session::auth::Method) -> bool +impl core::fmt::Debug for krishiv_session::auth::Method +pub fn krishiv_session::auth::Method::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::hash::Hash for krishiv_session::auth::Method +pub fn krishiv_session::auth::Method::hash<__H: core::hash::Hasher>(&self, &mut __H) +impl core::marker::Copy for krishiv_session::auth::Method +impl core::marker::StructuralPartialEq for krishiv_session::auth::Method +pub enum krishiv_session::auth::Role +pub krishiv_session::auth::Role::Admin +pub krishiv_session::auth::Role::Operator +pub krishiv_session::auth::Role::Viewer +impl krishiv_session::auth::Role +pub fn krishiv_session::auth::Role::as_str(self) -> &'static str +pub fn krishiv_session::auth::Role::parse(&str) -> core::option::Option +impl core::clone::Clone for krishiv_session::auth::Role +pub fn krishiv_session::auth::Role::clone(&self) -> krishiv_session::auth::Role +impl core::cmp::Eq for krishiv_session::auth::Role +impl core::cmp::Ord for krishiv_session::auth::Role +pub fn krishiv_session::auth::Role::cmp(&self, &krishiv_session::auth::Role) -> core::cmp::Ordering +impl core::cmp::PartialEq for krishiv_session::auth::Role +pub fn krishiv_session::auth::Role::eq(&self, &krishiv_session::auth::Role) -> bool +impl core::cmp::PartialOrd for krishiv_session::auth::Role +pub fn krishiv_session::auth::Role::partial_cmp(&self, &krishiv_session::auth::Role) -> core::option::Option +impl core::fmt::Debug for krishiv_session::auth::Role +pub fn krishiv_session::auth::Role::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for krishiv_session::auth::Role +pub fn krishiv_session::auth::Role::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::hash::Hash for krishiv_session::auth::Role +pub fn krishiv_session::auth::Role::hash<__H: core::hash::Hasher>(&self, &mut __H) +impl core::marker::Copy for krishiv_session::auth::Role +impl core::marker::StructuralPartialEq for krishiv_session::auth::Role +#[non_exhaustive] pub enum krishiv_session::auth::SecretRef +pub krishiv_session::auth::SecretRef::Env(alloc::string::String) +pub krishiv_session::auth::SecretRef::File(std::path::PathBuf) +pub krishiv_session::auth::SecretRef::Inline(krishiv_common::secret::SecretString) +impl krishiv_session::auth::SecretRef +pub fn krishiv_session::auth::SecretRef::is_inline(&self) -> bool +pub fn krishiv_session::auth::SecretRef::parse(&str, &str, bool) -> core::result::Result +pub fn krishiv_session::auth::SecretRef::resolve(&self, &str) -> core::result::Result +impl core::clone::Clone for krishiv_session::auth::SecretRef +pub fn krishiv_session::auth::SecretRef::clone(&self) -> krishiv_session::auth::SecretRef +impl core::cmp::Eq for krishiv_session::auth::SecretRef +impl core::cmp::PartialEq for krishiv_session::auth::SecretRef +pub fn krishiv_session::auth::SecretRef::eq(&self, &krishiv_session::auth::SecretRef) -> bool +impl core::fmt::Debug for krishiv_session::auth::SecretRef +pub fn krishiv_session::auth::SecretRef::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for krishiv_session::auth::SecretRef +#[non_exhaustive] pub enum krishiv_session::auth::Surface +pub krishiv_session::auth::Surface::Admin +pub krishiv_session::auth::Surface::Connect +pub krishiv_session::auth::Surface::Metrics +pub krishiv_session::auth::Surface::Ui +impl krishiv_session::auth::Surface +pub const krishiv_session::auth::Surface::ALL: [Self; 4] +pub fn krishiv_session::auth::Surface::as_str(self) -> &'static str +impl core::clone::Clone for krishiv_session::auth::Surface +pub fn krishiv_session::auth::Surface::clone(&self) -> krishiv_session::auth::Surface +impl core::cmp::Eq for krishiv_session::auth::Surface +impl core::cmp::PartialEq for krishiv_session::auth::Surface +pub fn krishiv_session::auth::Surface::eq(&self, &krishiv_session::auth::Surface) -> bool +impl core::fmt::Debug for krishiv_session::auth::Surface +pub fn krishiv_session::auth::Surface::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::hash::Hash for krishiv_session::auth::Surface +pub fn krishiv_session::auth::Surface::hash<__H: core::hash::Hasher>(&self, &mut __H) +impl core::marker::Copy for krishiv_session::auth::Surface +impl core::marker::StructuralPartialEq for krishiv_session::auth::Surface +#[non_exhaustive] pub struct krishiv_session::auth::AuthConfig +pub krishiv_session::auth::AuthConfig::throttle: krishiv_session::auth::ThrottleConfig +pub krishiv_session::auth::AuthConfig::tokens: alloc::vec::Vec +impl krishiv_session::auth::AuthConfig +pub fn krishiv_session::auth::AuthConfig::from_settings<'a>(impl core::iter::traits::collect::IntoIterator) -> core::result::Result +pub fn krishiv_session::auth::AuthConfig::is_configured(&self) -> bool +impl core::clone::Clone for krishiv_session::auth::AuthConfig +pub fn krishiv_session::auth::AuthConfig::clone(&self) -> krishiv_session::auth::AuthConfig +impl core::cmp::Eq for krishiv_session::auth::AuthConfig +impl core::cmp::PartialEq for krishiv_session::auth::AuthConfig +pub fn krishiv_session::auth::AuthConfig::eq(&self, &krishiv_session::auth::AuthConfig) -> bool +impl core::default::Default for krishiv_session::auth::AuthConfig +pub fn krishiv_session::auth::AuthConfig::default() -> krishiv_session::auth::AuthConfig +impl core::fmt::Debug for krishiv_session::auth::AuthConfig +pub fn krishiv_session::auth::AuthConfig::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for krishiv_session::auth::AuthConfig +#[non_exhaustive] pub struct krishiv_session::auth::Authenticated +pub krishiv_session::auth::Authenticated::method: krishiv_session::auth::Method +pub krishiv_session::auth::Authenticated::principal: krishiv_common::principal::Principal +pub krishiv_session::auth::Authenticated::role: core::option::Option +impl krishiv_session::auth::Authenticated +pub fn krishiv_session::auth::Authenticated::new(krishiv_common::principal::Principal, core::option::Option, krishiv_session::auth::Method) -> Self +impl core::clone::Clone for krishiv_session::auth::Authenticated +pub fn krishiv_session::auth::Authenticated::clone(&self) -> krishiv_session::auth::Authenticated +impl core::cmp::Eq for krishiv_session::auth::Authenticated +impl core::cmp::PartialEq for krishiv_session::auth::Authenticated +pub fn krishiv_session::auth::Authenticated::eq(&self, &krishiv_session::auth::Authenticated) -> bool +impl core::fmt::Debug for krishiv_session::auth::Authenticated +pub fn krishiv_session::auth::Authenticated::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for krishiv_session::auth::Authenticated +#[non_exhaustive] pub struct krishiv_session::auth::Credentials<'a> +pub krishiv_session::auth::Credentials::bearer: core::option::Option<&'a str> +pub krishiv_session::auth::Credentials::peer: core::net::socket_addr::SocketAddr +pub krishiv_session::auth::Credentials::surface: krishiv_session::auth::Surface +impl<'a> krishiv_session::auth::Credentials<'a> +pub fn krishiv_session::auth::Credentials<'a>::new(core::net::socket_addr::SocketAddr, krishiv_session::auth::Surface) -> Self +pub fn krishiv_session::auth::Credentials<'a>::with_authorization_header(self, &'a str) -> Self +pub fn krishiv_session::auth::Credentials<'a>::with_bearer(self, &'a str) -> Self +impl core::fmt::Debug for krishiv_session::auth::Credentials<'_> +pub fn krishiv_session::auth::Credentials<'_>::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl<'a> core::clone::Clone for krishiv_session::auth::Credentials<'a> +pub fn krishiv_session::auth::Credentials<'a>::clone(&self) -> krishiv_session::auth::Credentials<'a> +impl<'a> core::marker::Copy for krishiv_session::auth::Credentials<'a> +#[non_exhaustive] pub struct krishiv_session::auth::FailureCount +pub krishiv_session::auth::FailureCount::count: u64 +pub krishiv_session::auth::FailureCount::method: krishiv_session::auth::Method +pub krishiv_session::auth::FailureCount::surface: krishiv_session::auth::Surface +impl core::clone::Clone for krishiv_session::auth::FailureCount +pub fn krishiv_session::auth::FailureCount::clone(&self) -> krishiv_session::auth::FailureCount +impl core::cmp::Eq for krishiv_session::auth::FailureCount +impl core::cmp::PartialEq for krishiv_session::auth::FailureCount +pub fn krishiv_session::auth::FailureCount::eq(&self, &krishiv_session::auth::FailureCount) -> bool +impl core::fmt::Debug for krishiv_session::auth::FailureCount +pub fn krishiv_session::auth::FailureCount::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Copy for krishiv_session::auth::FailureCount +impl core::marker::StructuralPartialEq for krishiv_session::auth::FailureCount +pub struct krishiv_session::auth::FailureThrottle +impl krishiv_session::auth::FailureThrottle +pub fn krishiv_session::auth::FailureThrottle::is_blocked(&self, core::net::ip_addr::IpAddr, std::time::Instant) -> bool +pub fn krishiv_session::auth::FailureThrottle::new(krishiv_session::auth::ThrottleConfig) -> Self +pub fn krishiv_session::auth::FailureThrottle::record_failure(&self, core::net::ip_addr::IpAddr, std::time::Instant) -> bool +impl core::fmt::Debug for krishiv_session::auth::FailureThrottle +pub fn krishiv_session::auth::FailureThrottle::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct krishiv_session::auth::ServerAuth +impl krishiv_session::auth::ServerAuth +pub fn krishiv_session::auth::ServerAuth::authenticate_at(&self, &krishiv_session::auth::Credentials<'_>, std::time::Instant) -> core::result::Result +pub fn krishiv_session::auth::ServerAuth::failure_counts(&self) -> alloc::vec::Vec +pub fn krishiv_session::auth::ServerAuth::is_enabled(&self) -> bool +pub fn krishiv_session::auth::ServerAuth::new(&krishiv_session::auth::AuthConfig) -> core::result::Result +impl core::fmt::Debug for krishiv_session::auth::ServerAuth +pub fn krishiv_session::auth::ServerAuth::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl krishiv_session::auth::Authenticator for krishiv_session::auth::ServerAuth +pub fn krishiv_session::auth::ServerAuth::authenticate<'a>(&'a self, &'a krishiv_session::auth::Credentials<'a>) -> futures_core::future::BoxFuture<'a, core::result::Result> +pub struct krishiv_session::auth::StaticTokens +impl krishiv_session::auth::StaticTokens +pub fn krishiv_session::auth::StaticTokens::build(&[krishiv_session::auth::TokenSpec]) -> core::result::Result +pub fn krishiv_session::auth::StaticTokens::is_empty(&self) -> bool +pub fn krishiv_session::auth::StaticTokens::len(&self) -> usize +pub fn krishiv_session::auth::StaticTokens::lookup(&self, &str) -> core::option::Option<(&krishiv_common::principal::Principal, krishiv_session::auth::Role)> +impl core::clone::Clone for krishiv_session::auth::StaticTokens +pub fn krishiv_session::auth::StaticTokens::clone(&self) -> krishiv_session::auth::StaticTokens +impl core::default::Default for krishiv_session::auth::StaticTokens +pub fn krishiv_session::auth::StaticTokens::default() -> krishiv_session::auth::StaticTokens +impl core::fmt::Debug for krishiv_session::auth::StaticTokens +pub fn krishiv_session::auth::StaticTokens::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +#[non_exhaustive] pub struct krishiv_session::auth::ThrottleConfig +pub krishiv_session::auth::ThrottleConfig::block_for: core::time::Duration +pub krishiv_session::auth::ThrottleConfig::max_failures: u32 +pub krishiv_session::auth::ThrottleConfig::window: core::time::Duration +impl core::clone::Clone for krishiv_session::auth::ThrottleConfig +pub fn krishiv_session::auth::ThrottleConfig::clone(&self) -> krishiv_session::auth::ThrottleConfig +impl core::cmp::Eq for krishiv_session::auth::ThrottleConfig +impl core::cmp::PartialEq for krishiv_session::auth::ThrottleConfig +pub fn krishiv_session::auth::ThrottleConfig::eq(&self, &krishiv_session::auth::ThrottleConfig) -> bool +impl core::default::Default for krishiv_session::auth::ThrottleConfig +pub fn krishiv_session::auth::ThrottleConfig::default() -> Self +impl core::fmt::Debug for krishiv_session::auth::ThrottleConfig +pub fn krishiv_session::auth::ThrottleConfig::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Copy for krishiv_session::auth::ThrottleConfig +impl core::marker::StructuralPartialEq for krishiv_session::auth::ThrottleConfig +#[non_exhaustive] pub struct krishiv_session::auth::TokenSpec +pub krishiv_session::auth::TokenSpec::kind: krishiv_common::principal::PrincipalKind +pub krishiv_session::auth::TokenSpec::name: alloc::string::String +pub krishiv_session::auth::TokenSpec::principal: alloc::string::String +pub krishiv_session::auth::TokenSpec::role: krishiv_session::auth::Role +pub krishiv_session::auth::TokenSpec::secret: krishiv_session::auth::SecretRef +impl core::clone::Clone for krishiv_session::auth::TokenSpec +pub fn krishiv_session::auth::TokenSpec::clone(&self) -> krishiv_session::auth::TokenSpec +impl core::cmp::Eq for krishiv_session::auth::TokenSpec +impl core::cmp::PartialEq for krishiv_session::auth::TokenSpec +pub fn krishiv_session::auth::TokenSpec::eq(&self, &krishiv_session::auth::TokenSpec) -> bool +impl core::fmt::Debug for krishiv_session::auth::TokenSpec +pub fn krishiv_session::auth::TokenSpec::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for krishiv_session::auth::TokenSpec +pub trait krishiv_session::auth::Authenticator: core::marker::Send + core::marker::Sync +pub fn krishiv_session::auth::Authenticator::authenticate<'a>(&'a self, &'a krishiv_session::auth::Credentials<'a>) -> futures_core::future::BoxFuture<'a, core::result::Result> +impl krishiv_session::auth::Authenticator for krishiv_session::auth::ServerAuth +pub fn krishiv_session::auth::ServerAuth::authenticate<'a>(&'a self, &'a krishiv_session::auth::Credentials<'a>) -> futures_core::future::BoxFuture<'a, core::result::Result> +pub fn krishiv_session::auth::bearer_token(&str) -> core::option::Option<&str> +pub fn krishiv_session::auth::is_auth_key(&str) -> bool pub mod krishiv_session::functions pub trait krishiv_session::functions::IntoLiteral pub fn krishiv_session::functions::IntoLiteral::into_literal(self) -> krishiv_plan::literal::Literal @@ -622,10 +844,8 @@ pub fn krishiv_session::Session::read(&self) -> krishiv_session::DataFrameReader pub fn krishiv_session::Session::refresh_files(&self) pub fn krishiv_session::Session::refresh_table(&self, &str) pub fn krishiv_session::Session::register_batches(&self, &str, arrow_schema::schema::SchemaRef, alloc::vec::Vec) -> core::result::Result<(), krishiv_common::error::KrishivError> -pub fn krishiv_session::Session::register_python_udf(&self, krishiv_session::PythonUdfDef) -> krishiv_session::PythonUdfEntry pub fn krishiv_session::Session::register_udaf(&self, impl krishiv_udf_sdk::aggregate::AggregateUdf) -> core::result::Result<(), krishiv_common::error::KrishivError> pub fn krishiv_session::Session::register_udf(&self, impl krishiv_udf_sdk::udf::ScalarUdf) -> core::result::Result<(), krishiv_common::error::KrishivError> -pub fn krishiv_session::Session::set_python_udf_runtime(&self, alloc::sync::Arc) pub async fn krishiv_session::Session::sql(&self, &str) -> core::result::Result pub fn krishiv_session::Session::table(&self, &str) -> krishiv_session::DataFrame pub fn krishiv_session::Session::temp_table_names(&self) -> alloc::vec::Vec @@ -647,6 +867,9 @@ pub fn krishiv_session::Session::trace_id(&self) -> core::option::Option core::result::Result, krishiv_common::error::KrishivError> impl krishiv_session::Session +pub fn krishiv_session::Session::register_python_udf(&self, krishiv_session::PythonUdfDef) -> krishiv_session::PythonUdfEntry +pub fn krishiv_session::Session::set_python_udf_runtime(&self, alloc::sync::Arc) +impl krishiv_session::Session pub async fn krishiv_session::Session::statement_info(&self, &str) -> core::result::Result impl core::clone::Clone for krishiv_session::Session pub fn krishiv_session::Session::clone(&self) -> krishiv_session::Session From 6adc781489058a198dca4d831805e4f4fe0dbce6 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:52:27 +0100 Subject: [PATCH 03/47] session: auth review fixes (one role per principal, bounded throttle, IPv6 /64 keys, malformed headers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the Opus review of p3/t002-auth-core: - two tokens may not give one principal different roles (ADR 0046 §2); - throttle window and block are 1s to 24h and the deadline cannot overflow; a full table is swept at most once a second; - throttle keys are the IPv4 address or the IPv6 /64 (IPv4-mapped addresses canonicalised); - a present but unusable Authorization header is an invalid token (Spark's message), counted and throttled; - the secret file is checked and read through one handle; tokens over 16 KiB are refused unhashed; throttled requests count as method `throttle`; a test that failure logs never contain a token. Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-session/public-api.txt | 4 +- crates/krishiv-session/src/auth/config.rs | 17 +- crates/krishiv-session/src/auth/mod.rs | 17 +- crates/krishiv-session/src/auth/secret_ref.rs | 21 ++- crates/krishiv-session/src/auth/server.rs | 15 +- crates/krishiv-session/src/auth/tests.rs | 168 +++++++++++++++++- crates/krishiv-session/src/auth/throttle.rs | 84 ++++++--- crates/krishiv-session/src/auth/tokens.rs | 19 +- 8 files changed, 303 insertions(+), 42 deletions(-) diff --git a/crates/krishiv-session/public-api.txt b/crates/krishiv-session/public-api.txt index 27d0a309d..5c48f670e 100644 --- a/crates/krishiv-session/public-api.txt +++ b/crates/krishiv-session/public-api.txt @@ -155,9 +155,10 @@ impl core::marker::StructuralPartialEq for krishiv_session::auth::AuthFailure pub krishiv_session::auth::Method::Mtls pub krishiv_session::auth::Method::None pub krishiv_session::auth::Method::Oidc +pub krishiv_session::auth::Method::Throttle pub krishiv_session::auth::Method::Token impl krishiv_session::auth::Method -pub const krishiv_session::auth::Method::ALL: [Self; 4] +pub const krishiv_session::auth::Method::ALL: [Self; 5] pub fn krishiv_session::auth::Method::as_str(self) -> &'static str impl core::clone::Clone for krishiv_session::auth::Method pub fn krishiv_session::auth::Method::clone(&self) -> krishiv_session::auth::Method @@ -261,6 +262,7 @@ pub fn krishiv_session::auth::Authenticated::fmt(&self, &mut core::fmt::Formatte impl core::marker::StructuralPartialEq for krishiv_session::auth::Authenticated #[non_exhaustive] pub struct krishiv_session::auth::Credentials<'a> pub krishiv_session::auth::Credentials::bearer: core::option::Option<&'a str> +pub krishiv_session::auth::Credentials::malformed: bool pub krishiv_session::auth::Credentials::peer: core::net::socket_addr::SocketAddr pub krishiv_session::auth::Credentials::surface: krishiv_session::auth::Surface impl<'a> krishiv_session::auth::Credentials<'a> diff --git a/crates/krishiv-session/src/auth/config.rs b/crates/krishiv-session/src/auth/config.rs index 670c004de..7779f9c6d 100644 --- a/crates/krishiv-session/src/auth/config.rs +++ b/crates/krishiv-session/src/auth/config.rs @@ -144,8 +144,8 @@ impl AuthConfig { .parse() .map_err(|_| invalid(key, raw, "expected a whole number >= 0"))?; } - "failureThrottle.window" => throttle.window = parse_duration(key, raw)?, - "failureThrottle.blockFor" => throttle.block_for = parse_duration(key, raw)?, + "failureThrottle.window" => throttle.window = bounded_duration(key, raw)?, + "failureThrottle.blockFor" => throttle.block_for = bounded_duration(key, raw)?, _ => return Err(unknown(key)), } } @@ -239,3 +239,16 @@ fn parse_duration(key: &str, raw: &str) -> Result { }; Ok(d) } + +/// The bounds of the throttle's durations: zero would switch it off silently (use +/// `maxFailures = 0` for that), and a huge value would overflow the clock. +const MIN_THROTTLE: Duration = Duration::from_secs(1); +const MAX_THROTTLE: Duration = Duration::from_hours(24); + +fn bounded_duration(key: &str, raw: &str) -> Result { + let d = parse_duration(key, raw)?; + if !(MIN_THROTTLE..=MAX_THROTTLE).contains(&d) { + return Err(invalid(key, raw, "must be between 1s and 24h")); + } + Ok(d) +} diff --git a/crates/krishiv-session/src/auth/mod.rs b/crates/krishiv-session/src/auth/mod.rs index 7a4b612ff..89f5f979d 100644 --- a/crates/krishiv-session/src/auth/mod.rs +++ b/crates/krishiv-session/src/auth/mod.rs @@ -115,13 +115,21 @@ pub enum Method { Oidc, /// A client certificate (a later unit). Mtls, + /// Refused before any method ran (the peer is throttled). + Throttle, /// No credential was presented. None, } impl Method { /// Every method, in counter order. - pub const ALL: [Self; 4] = [Self::Token, Self::Oidc, Self::Mtls, Self::None]; + pub const ALL: [Self; 5] = [ + Self::Token, + Self::Oidc, + Self::Mtls, + Self::Throttle, + Self::None, + ]; /// The label value: `token`, `oidc`, `mtls` or `none`. #[must_use] @@ -130,6 +138,7 @@ impl Method { Self::Token => "token", Self::Oidc => "oidc", Self::Mtls => "mtls", + Self::Throttle => "throttle", Self::None => "none", } } @@ -145,6 +154,9 @@ pub struct Credentials<'a> { pub peer: SocketAddr, /// The listener. pub surface: Surface, + /// An `Authorization` header was present but not a usable `Bearer` token (`Basic ..`, + /// `Bearer` with no token): Spark answers "Invalid authentication token", not "missing". + pub malformed: bool, } impl<'a> Credentials<'a> { @@ -155,6 +167,7 @@ impl<'a> Credentials<'a> { bearer: None, peer, surface, + malformed: false, } } @@ -170,6 +183,7 @@ impl<'a> Credentials<'a> { #[must_use] pub fn with_authorization_header(mut self, value: &'a str) -> Self { self.bearer = bearer_token(value); + self.malformed = self.bearer.is_none(); self } } @@ -183,6 +197,7 @@ impl fmt::Debug for Credentials<'_> { ) .field("peer", &self.peer) .field("surface", &self.surface) + .field("malformed", &self.malformed) .finish() } } diff --git a/crates/krishiv-session/src/auth/secret_ref.rs b/crates/krishiv-session/src/auth/secret_ref.rs index 2acfb7f4b..94850d12d 100644 --- a/crates/krishiv-session/src/auth/secret_ref.rs +++ b/crates/krishiv-session/src/auth/secret_ref.rs @@ -70,10 +70,16 @@ impl SecretRef { ) }), Self::File(path) => { + use std::io::Read as _; let shown = format!("file:{}", path.display()); - check_private(key, &shown, path)?; - let mut text = std::fs::read_to_string(path) - .map_err(|e| invalid(key, &shown, &format!("cannot be read: {}", e.kind())))?; + let unreadable = |e: std::io::Error| { + invalid(key, &shown, &format!("cannot be read: {}", e.kind())) + }; + // One handle for the check and the read, so the file checked is the file read. + let mut file = std::fs::File::open(path).map_err(unreadable)?; + check_private(key, &shown, &file.metadata().map_err(unreadable)?)?; + let mut text = String::new(); + file.read_to_string(&mut text).map_err(unreadable)?; if text.ends_with('\n') { text.pop(); if text.ends_with('\r') { @@ -95,10 +101,11 @@ impl SecretRef { /// Refuses a secret file that group or others may read. #[cfg(unix)] -fn check_private(key: &str, shown: &str, path: &std::path::Path) -> Result<(), KrishivError> { +fn check_private(key: &str, shown: &str, meta: &std::fs::Metadata) -> Result<(), KrishivError> { use std::os::unix::fs::PermissionsExt; - let meta = std::fs::metadata(path) - .map_err(|e| invalid(key, shown, &format!("cannot be read: {}", e.kind())))?; + if !meta.is_file() { + return Err(invalid(key, shown, "not a regular file")); + } if meta.permissions().mode() & 0o077 != 0 { return Err(invalid( key, @@ -111,7 +118,7 @@ fn check_private(key: &str, shown: &str, path: &std::path::Path) -> Result<(), K /// Other platforms have no mode bits to check. #[cfg(not(unix))] -fn check_private(_key: &str, _shown: &str, _path: &std::path::Path) -> Result<(), KrishivError> { +fn check_private(_key: &str, _shown: &str, _meta: &std::fs::Metadata) -> Result<(), KrishivError> { Ok(()) } diff --git a/crates/krishiv-session/src/auth/server.rs b/crates/krishiv-session/src/auth/server.rs index 3927f6aed..c964e6cb2 100644 --- a/crates/krishiv-session/src/auth/server.rs +++ b/crates/krishiv-session/src/auth/server.rs @@ -18,6 +18,9 @@ use super::throttle::FailureThrottle; use super::tokens::StaticTokens; use super::{AuthFailure, Authenticated, Authenticator, Credentials, Method, Surface}; +/// The longest bearer token hashed (design §3.3's JWT cap; static tokens are far shorter). +const MAX_TOKEN_BYTES: usize = 16 * 1024; + /// One failure counter (`krishiv_auth_failed_total{surface, method}`, ADR 0046 §8). #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] @@ -79,12 +82,16 @@ impl ServerAuth { ) -> Result { let ip = credentials.peer.ip(); if self.throttle.is_blocked(ip, now) { - return Err(self.fail(credentials, Method::None, AuthFailure::Throttled)); + return Err(self.fail(credentials, Method::Throttle, AuthFailure::Throttled)); } - let Some(token) = credentials.bearer else { - return Err(self.fail(credentials, Method::None, AuthFailure::Missing)); + let token = match credentials.bearer { + Some(token) => Some(token), + None if credentials.malformed => None, + None => return Err(self.fail(credentials, Method::None, AuthFailure::Missing)), }; - match self.tokens.lookup(token) { + // A token longer than any credential this server issues is refused unhashed. + let token = token.filter(|t| t.len() <= MAX_TOKEN_BYTES); + match token.and_then(|t| self.tokens.lookup(t)) { Some((principal, role)) => Ok(Authenticated::new( principal.clone(), Some(role), diff --git a/crates/krishiv-session/src/auth/tests.rs b/crates/krishiv-session/src/auth/tests.rs index 778ed295f..2a27a81c3 100644 --- a/crates/krishiv-session/src/auth/tests.rs +++ b/crates/krishiv-session/src/auth/tests.rs @@ -10,7 +10,7 @@ use krishiv_common::error::{ErrorClass, KrishivError}; use krishiv_common::principal::PrincipalKind; use super::config::SPARK_TOKEN_KEY; -use super::throttle::{MAX_PEERS, step}; +use super::throttle::{MAX_PEERS, key, step}; use super::{ AuthConfig, AuthFailure, Authenticator, Credentials, FailureThrottle, Method, Role, SecretRef, ServerAuth, StaticTokens, Surface, ThrottleConfig, bearer_token, is_auth_key, @@ -167,12 +167,12 @@ fn throttle_settings_parse() { let c = config(&[ ("krishiv.auth.failureThrottle.maxFailures", "5"), ("krishiv.auth.failureThrottle.window", "2m"), - ("krishiv.auth.failureThrottle.blockFor", "500ms"), + ("krishiv.auth.failureThrottle.blockFor", "1500ms"), ]) .unwrap(); assert_eq!(c.throttle.max_failures, 5); assert_eq!(c.throttle.window, Duration::from_mins(2)); - assert_eq!(c.throttle.block_for, Duration::from_millis(500)); + assert_eq!(c.throttle.block_for, Duration::from_millis(1500)); assert!(!c.is_configured()); for bad in ["soon", "5d", "-1s"] { let e = config(&[("krishiv.auth.failureThrottle.window", bad)]).unwrap_err(); @@ -320,7 +320,8 @@ fn throttle_off_and_bounded() { let t = FailureThrottle::new(ThrottleConfig::default()); for i in 0..=MAX_PEERS { - let ip = IpAddr::V6(std::net::Ipv6Addr::from(u128::try_from(i).unwrap())); + // One /64 per entry: addresses inside one /64 share a key. + let ip = IpAddr::V6(std::net::Ipv6Addr::from(u128::try_from(i).unwrap() << 64)); t.record_failure(ip, now); } assert_eq!( @@ -420,3 +421,162 @@ fn authenticator_trait_object() { let r = futures::executor::block_on(auth.authenticate(&c)).unwrap(); assert_eq!(r.principal.name, "alice"); } + +#[test] +fn one_principal_has_one_role() { + let dir = tempfile::tempdir().unwrap(); + let one = secret_file(dir.path(), "one", TOKEN_A, 0o600); + let two = secret_file(dir.path(), "two", TOKEN_B, 0o600); + let tokens = |role_b: &str| { + config(&[ + ("krishiv.auth.tokens.a.secret", &one), + ("krishiv.auth.tokens.a.principal", "alice"), + ("krishiv.auth.tokens.a.role", "viewer"), + ("krishiv.auth.tokens.b.secret", &two), + ("krishiv.auth.tokens.b.principal", "alice"), + ("krishiv.auth.tokens.b.role", role_b), + ]) + .unwrap() + }; + let e = StaticTokens::build(&tokens("admin").tokens).unwrap_err(); + assert!(e.to_string().contains("exactly one role"), "{e}"); + // Two tokens of one principal with one role (rotation) are fine. + let t = StaticTokens::build(&tokens("viewer").tokens).unwrap(); + assert_eq!(t.len(), 2); + // Spark's token is admin: naming a viewer principal `spark` collides too. + let c = config(&[ + (SPARK_TOKEN_KEY, TOKEN_B), + ("krishiv.auth.tokens.a.secret", &one), + ("krishiv.auth.tokens.a.principal", "spark"), + ("krishiv.auth.tokens.a.role", "viewer"), + ]) + .unwrap(); + assert!(StaticTokens::build(&c.tokens).is_err()); +} + +#[test] +fn throttle_durations_are_bounded() { + for bad in ["0s", "25h", "18446744073709551615s"] { + for k in [ + "krishiv.auth.failureThrottle.window", + "krishiv.auth.failureThrottle.blockFor", + ] { + let e = config(&[(k, bad)]).unwrap_err(); + assert!(invalid_config(&e), "{k}={bad}: {e}"); + } + } + // A config built in code with a huge block does not overflow the clock. + let t = FailureThrottle::new(ThrottleConfig { + max_failures: 1, + window: Duration::from_secs(1), + block_for: Duration::MAX, + }); + let now = Instant::now(); + assert!(t.record_failure(peer(1).ip(), now)); + assert!(t.is_blocked(peer(1).ip(), now)); +} + +#[test] +fn throttle_keys_ipv6_by_64_and_maps_ipv4() { + let a: IpAddr = "2001:db8:1:2:aaaa::1".parse().unwrap(); + let b: IpAddr = "2001:db8:1:2:bbbb::9".parse().unwrap(); + let c: IpAddr = "2001:db8:1:3::1".parse().unwrap(); + assert_eq!(key(a), key(b)); + assert_ne!(key(a), key(c)); + let mapped: IpAddr = "::ffff:10.0.0.1".parse().unwrap(); + assert_eq!(key(mapped), peer(1).ip()); + let cfg = ThrottleConfig { + max_failures: 2, + ..ThrottleConfig::default() + }; + let t = FailureThrottle::new(cfg); + let now = Instant::now(); + t.record_failure(a, now); + assert!( + t.record_failure(b, now), + "rotating inside the /64 still counts" + ); + assert!(t.is_blocked(a, now) && !t.is_blocked(c, now)); +} + +#[test] +fn a_malformed_header_is_an_invalid_token() { + let dir = tempfile::tempdir().unwrap(); + let auth = ServerAuth::new(&two_tokens(dir.path())).unwrap(); + let now = Instant::now(); + for header in ["Basic dXNlcjpwYXNz", "Bearer ", "Bearer"] { + let c = Credentials::new(peer(4), Surface::Connect).with_authorization_header(header); + assert_eq!( + auth.authenticate_at(&c, now), + Err(AuthFailure::Invalid), + "{header}" + ); + } + let tokens = auth + .failure_counts() + .into_iter() + .find(|c| c.surface == Surface::Connect && c.method == Method::Token) + .map(|c| c.count); + assert_eq!(tokens, Some(3)); +} + +#[test] +fn throttled_requests_count_as_throttle() { + let dir = tempfile::tempdir().unwrap(); + let auth = ServerAuth::new(&two_tokens(dir.path())).unwrap(); + let now = Instant::now(); + let guess = Credentials::new(peer(5), Surface::Ui).with_bearer("guess"); + for _ in 0..=ThrottleConfig::default().max_failures { + let _ = auth.authenticate_at(&guess, now); + } + let throttled = auth + .failure_counts() + .into_iter() + .find(|c| c.surface == Surface::Ui && c.method == Method::Throttle) + .map(|c| c.count); + assert_eq!(throttled, Some(1)); +} + +#[test] +fn failure_logs_never_contain_the_token() { + use std::sync::{Arc, Mutex}; + + #[derive(Clone, Default)] + struct Sink(Arc>>); + impl std::io::Write for Sink { + fn write(&mut self, b: &[u8]) -> std::io::Result { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .extend_from_slice(b); + Ok(b.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let sink = Sink::default(); + let writer = sink.clone(); + let subscriber = tracing_subscriber::fmt() + .with_max_level(tracing::Level::TRACE) + .with_writer(move || writer.clone()) + .finish(); + let dir = tempfile::tempdir().unwrap(); + let near_miss = format!("{}x", &TOKEN_A[..TOKEN_A.len() - 1]); + tracing::subscriber::with_default(subscriber, || { + let auth = ServerAuth::new(&two_tokens(dir.path())).unwrap(); + let now = Instant::now(); + for token in [near_miss.as_str(), TOKEN_A, "short"] { + let c = Credentials::new(peer(6), Surface::Admin).with_bearer(token); + let _ = auth.authenticate_at(&c, now); + } + let header = format!("Basic {TOKEN_B}"); + let c = Credentials::new(peer(6), Surface::Admin).with_authorization_header(&header); + let _ = auth.authenticate_at(&c, now); + }); + let logged = String::from_utf8(sink.0.lock().unwrap().clone()).unwrap(); + assert!(logged.contains("authentication failed"), "{logged}"); + for secret in [TOKEN_A, TOKEN_B, near_miss.as_str()] { + assert!(!logged.contains(secret), "{logged}"); + } +} diff --git a/crates/krishiv-session/src/auth/throttle.rs b/crates/krishiv-session/src/auth/throttle.rs index 2f5770890..bdd986814 100644 --- a/crates/krishiv-session/src/auth/throttle.rs +++ b/crates/krishiv-session/src/auth/throttle.rs @@ -2,19 +2,44 @@ //! Failure throttling per peer address (design §3.1; threat model T-A4): after //! `max_failures` failures within `window`, the address is refused for `block_for`. //! -//! The table is bounded: past [`MAX_PEERS`] addresses, stale entries are dropped first; when -//! every entry is live, a new address is not tracked (throttling is a brute-force brake, not an -//! access control, so it degrades open rather than refusing everyone). +//! Addresses are keyed as a client controls them: an IPv4 address (an IPv4-mapped IPv6 one +//! included) whole, an IPv6 address by its /64, since one host usually holds a whole /64 and +//! could otherwise rotate through it. +//! +//! The table is bounded: past [`MAX_PEERS`] keys, stale entries are dropped first (at most one +//! sweep per [`SWEEP_EVERY`], so a full table costs no scan per request); when every entry is +//! live, a new key is not tracked (throttling is a brute-force brake, not an access control, so +//! it degrades open rather than refusing everyone). use std::collections::HashMap; -use std::net::IpAddr; +use std::net::{IpAddr, Ipv6Addr}; use std::sync::{Mutex, PoisonError}; -use std::time::Instant; +use std::time::{Duration, Instant}; use super::config::ThrottleConfig; -/// Most peer addresses tracked at once. +/// Most peer keys tracked at once. pub(crate) const MAX_PEERS: usize = 65_536; +/// Least time between two sweeps of a full table. +pub(crate) const SWEEP_EVERY: Duration = Duration::from_secs(1); + +/// The throttle key of `ip`: IPv4 whole, IPv6 by its /64. +pub(crate) fn key(ip: IpAddr) -> IpAddr { + match ip { + IpAddr::V4(v4) => IpAddr::V4(v4), + IpAddr::V6(v6) => match v6.to_ipv4_mapped() { + Some(v4) => IpAddr::V4(v4), + None => IpAddr::V6(Ipv6Addr::from(u128::from(v6) & !u128::from(u64::MAX))), + }, + } +} + +/// The table and when it was last swept. +#[derive(Debug, Default)] +struct Table { + peers: HashMap, + last_sweep: Option, +} /// One address's state. #[derive(Debug, Clone, Copy)] @@ -28,7 +53,7 @@ struct Peer { #[derive(Debug)] pub struct FailureThrottle { config: ThrottleConfig, - peers: Mutex>, + table: Mutex, } impl FailureThrottle { @@ -37,7 +62,7 @@ impl FailureThrottle { pub fn new(config: ThrottleConfig) -> Self { Self { config, - peers: Mutex::new(HashMap::new()), + table: Mutex::new(Table::default()), } } @@ -47,9 +72,10 @@ impl FailureThrottle { if self.config.max_failures == 0 { return false; } - let peers = self.peers.lock().unwrap_or_else(PoisonError::into_inner); - peers - .get(&peer) + let table = self.table.lock().unwrap_or_else(PoisonError::into_inner); + table + .peers + .get(&key(peer)) .and_then(|p| p.blocked_until) .is_some_and(|until| now < until) } @@ -59,15 +85,22 @@ impl FailureThrottle { if self.config.max_failures == 0 { return false; } - let mut peers = self.peers.lock().unwrap_or_else(PoisonError::into_inner); - if !peers.contains_key(&peer) && peers.len() >= MAX_PEERS { - let config = self.config; - peers.retain(|_, p| !is_stale(p, now, config)); - if peers.len() >= MAX_PEERS { + let peer = key(peer); + let mut table = self.table.lock().unwrap_or_else(PoisonError::into_inner); + if !table.peers.contains_key(&peer) && table.peers.len() >= MAX_PEERS { + let due = table + .last_sweep + .is_none_or(|t| now.saturating_duration_since(t) >= SWEEP_EVERY); + if due { + let config = self.config; + table.peers.retain(|_, p| !is_stale(p, now, config)); + table.last_sweep = Some(now); + } + if table.peers.len() >= MAX_PEERS { return false; } } - let p = peers.entry(peer).or_insert(Peer { + let p = table.peers.entry(peer).or_insert(Peer { window_start: now, failures: 0, blocked_until: None, @@ -83,8 +116,14 @@ impl FailureThrottle { } p.failures = p.failures.saturating_add(1); if p.blocked_until.is_none() && p.failures >= self.config.max_failures { - p.blocked_until = Some(now + self.config.block_for); - return true; + // The durations are bounded at parse time; a clock this close to its end blocks + // for as long as it can. + let until = now + .checked_add(self.config.block_for) + .or_else(|| now.checked_add(Duration::from_hours(24))) + .unwrap_or(now); + p.blocked_until = Some(until); + return until > now; } false } @@ -92,9 +131,10 @@ impl FailureThrottle { /// Addresses tracked now (tests). #[cfg(test)] pub(crate) fn tracked(&self) -> usize { - self.peers + self.table .lock() .unwrap_or_else(PoisonError::into_inner) + .peers .len() } } @@ -108,6 +148,6 @@ fn is_stale(p: &Peer, now: Instant, config: ThrottleConfig) -> bool { /// The window a test needs to step past. #[cfg(test)] -pub(crate) fn step(config: ThrottleConfig) -> std::time::Duration { - config.window.max(config.block_for) + std::time::Duration::from_secs(1) +pub(crate) fn step(config: ThrottleConfig) -> Duration { + config.window.max(config.block_for) + Duration::from_secs(1) } diff --git a/crates/krishiv-session/src/auth/tokens.rs b/crates/krishiv-session/src/auth/tokens.rs index 8f232ebf8..447899f94 100644 --- a/crates/krishiv-session/src/auth/tokens.rs +++ b/crates/krishiv-session/src/auth/tokens.rs @@ -37,7 +37,8 @@ impl StaticTokens { /// /// # Errors /// `KRISHIV_INVALID_CONFIG_VALUE` when a secret cannot be resolved, is shorter than 32 - /// bytes, or is the same as another token's secret. + /// bytes, or is the same as another token's secret, or when two tokens give one principal + /// different roles. pub fn build(specs: &[TokenSpec]) -> Result { let mut entries: Vec<(Entry, &str)> = Vec::with_capacity(specs.len()); for spec in specs { @@ -60,6 +61,22 @@ impl StaticTokens { )); } let principal = Principal::new(spec.principal.clone(), spec.kind); + // A principal has exactly one role (ADR 0046 §2): two tokens may name one principal + // (rotation), but only with the same role. + if let Some((other, other_name)) = entries + .iter() + .find(|(e, _)| e.principal.name == principal.name && e.role != spec.role) + { + return Err(invalid( + &format!("krishiv.auth.tokens.{}.role", spec.name), + spec.role.as_str(), + &format!( + "principal `{}` already has role {} through token `{other_name}`; a \ + principal has exactly one role", + principal.name, other.role + ), + )); + } entries.push(( Entry { digest, From 7d728e76058dd040dfa17913cecbf8b144af2c8a Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:01:06 +0100 Subject: [PATCH 04/47] plan, sql, session: ADR 0046 role matrix (RolePolicy), Browse/Operate/Manage/Impersonate (P3-T002 PR-14b-roles) - krishiv-plan: Privilege gains Browse, Operate, Manage and Impersonate (additive, non_exhaustive). authorize asks Select when a policy does not allow Browse, so providers that know only Select keep working. - krishiv-sql: SHOW TABLES/VIEWS/FUNCTIONS/COLUMNS/DATABASES/CREATE TABLE/ TBLPROPERTIES and plain DESCRIBE need Browse; SHOW TABLE EXTENDED, SHOW PARTITIONS and DESCRIBE EXTENDED|FORMATTED keep Select (statistics and partition values are data); CLEAR CACHE, REFRESH and ADD JAR need Manage on the cluster. - krishiv-session: ADMIN-class statements need Manage on the cluster (was Monitor); view metadata statements check Browse on the view. RolePolicy (auth/roles.rs) answers the matrix: viewer metadata and own system rows, operator data, client code at krishiv.auth.udf.minRole (admin until T014b), others' work (Operate) and Monitor, admin writes, DDL and Manage; Impersonate never; no role denies everything. - tests: roles_matrix over the statement classes for three roles, no_role_is_denied_even_metadata, a_denial_reveals_no_reason_or_existence, unit tests of the matrix; access_policy expectations updated. Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-plan/src/analyzer/access.rs | 14 +- crates/krishiv-plan/src/analyzer/catalog.rs | 24 +++- crates/krishiv-session/CLAUDE.md | 2 +- crates/krishiv-session/src/access.rs | 21 +-- crates/krishiv-session/src/auth/mod.rs | 2 + crates/krishiv-session/src/auth/roles.rs | 131 ++++++++++++++++++ crates/krishiv-session/src/auth/tests.rs | 78 +++++++++++ crates/krishiv-session/src/introspection.rs | 9 +- crates/krishiv-session/tests/access_policy.rs | 8 +- crates/krishiv-session/tests/roles_matrix.rs | 129 +++++++++++++++++ crates/krishiv-sql/src/classify.rs | 47 ++++--- 11 files changed, 428 insertions(+), 37 deletions(-) create mode 100644 crates/krishiv-session/src/auth/roles.rs create mode 100644 crates/krishiv-session/tests/roles_matrix.rs diff --git a/crates/krishiv-plan/src/analyzer/access.rs b/crates/krishiv-plan/src/analyzer/access.rs index 0e25bad6a..07de51a64 100644 --- a/crates/krishiv-plan/src/analyzer/access.rs +++ b/crates/krishiv-plan/src/analyzer/access.rs @@ -57,7 +57,19 @@ pub async fn authorize( Ok(AccessDecision::AllowWith(_)) | Err(_) => return Err(denied()), } } - match policy.check(principal, securable, privilege).await { + let decision = match policy.check(principal, securable, privilege).await { + // `Select` implies `Browse` (ADR 0046 §4): a provider that knows only `Select` keeps + // answering metadata commands as it did before `Browse` existed. + Ok(AccessDecision::Allow) => Ok(AccessDecision::Allow), + Ok(_) | Err(_) if privilege == Privilege::Browse => { + match policy.check(principal, securable, Privilege::Select).await { + Ok(AccessDecision::Allow) => Ok(AccessDecision::Allow), + _ => return Err(denied()), + } + } + other => other, + }; + match decision { Ok(AccessDecision::Allow) => Ok(()), Ok(AccessDecision::AllowWith(_)) if privilege == Privilege::Select => { Err(KrishivUnsupportedFeature { diff --git a/crates/krishiv-plan/src/analyzer/catalog.rs b/crates/krishiv-plan/src/analyzer/catalog.rs index a33941f0c..e125813c2 100644 --- a/crates/krishiv-plan/src/analyzer/catalog.rs +++ b/crates/krishiv-plan/src/analyzer/catalog.rs @@ -428,12 +428,26 @@ pub enum Privilege { Create, /// Drop (or replace) the object. Drop, - /// Read other principals' rows in `system.*` tables, and run the - /// cluster-scope statements of the `ADMIN` class. + /// Read other principals' rows in `system.*` tables (ADR 0046 §4: the + /// cluster-scope statements of the `ADMIN` class moved to [`Self::Manage`]). Monitor, /// Use a secret or connection without reading its value: the engine - /// resolves it on the principal's behalf (E12). + /// resolves it on the principal's behalf (E12). On `System("udf")`: send code the server + /// runs (Python UDFs, data sources, artifacts; ADR 0046 §4). Usage, + /// See that an object exists and its schema, without its rows (ADR 0046 §4, Unity + /// Catalog's `BROWSE`). `Select` implies it: [`super::authorize`] asks for `Select` when a + /// policy does not allow `Browse`, so providers that answer only `Select` keep working. + Browse, + /// Act on other principals' running work: cancel their queries, kill their sessions + /// (ADR 0046 §4; on `System("queries")` / `System("sessions")`). + Operate, + /// Change the server: cluster-scope statements, drain, config, resource groups (ADR 0046 + /// §4; on `System("cluster")`). + Manage, + /// Assert another principal's identity (ADR 0046 §5; on `System("principals")`). Never part + /// of a role. + Impersonate, } impl Privilege { @@ -455,6 +469,10 @@ impl fmt::Display for Privilege { Self::Drop => "DROP", Self::Monitor => "MONITOR", Self::Usage => "USAGE", + Self::Browse => "BROWSE", + Self::Operate => "OPERATE", + Self::Manage => "MANAGE", + Self::Impersonate => "IMPERSONATE", }) } } diff --git a/crates/krishiv-session/CLAUDE.md b/crates/krishiv-session/CLAUDE.md index 178968e7f..a496b9d2d 100644 --- a/crates/krishiv-session/CLAUDE.md +++ b/crates/krishiv-session/CLAUDE.md @@ -24,7 +24,7 @@ Paths are relative to this crate. - `src/query_fold.rs` the bookkeeping around one running query: `QueryStart` (counters read at start), `QueryFold`/`MetricStream`/`PendingFold` (the one fold of its metrics into the engine series and `system.queries` when the stream ends, fails or is dropped; holds the admission permit), `QueuedRow` (the history row of a query waiting for admission) - `src/admission.rs` the admission seam (E5): `AdmissionController::admit(&QueryRequest) -> Permit` asked once per query in `run_on_query_logged`; default `Unlimited`; a waiting `admit` shows the query `queued` in `system.queries`, a refusal closes the row `failed`, a drop while queued `cancelled`; the permit (slot, optional per-query memory budget) lives in the query's `QueryFold` and releases on drop; `tests/admission.rs` - `src/access.rs` `StatementInfo` / `Session::statement_info` (class + read securables with columns), write/DDL checks (`authorize_statement`), the `krishiv::audit` denial record, `PathPrefixPolicy` -- `src/auth/` (P3-T002, design `design/P3-T002-authentication.md`, ADR 0046) server authentication: `Role` (viewer < operator < admin), `Credentials` / `Authenticated` / `AuthFailure` (Spark's two `UNAUTHENTICATED` messages), the `Authenticator` trait and `ServerAuth` (throttle, then static tokens; one `krishiv::auth` warn record and one `(surface, method)` counter per failure, never the credential); `config.rs` `AuthConfig::from_settings` (`krishiv.auth.tokens..{secret,principal,kind,role}`, `spark.connect.authenticate.token` = one admin token named `spark`, `krishiv.auth.failureThrottle.*`; unknown `krishiv.auth.*` keys refused); `secret_ref.rs` `env:`/`file:` references (a file readable by group or others refuses startup); `tokens.rs` SHA-256 digests compared in constant time against every entry (secrets >= 32 bytes, no two tokens share one); `throttle.rs` per-address failure blocks, bounded at 65,536 addresses +- `src/auth/` (P3-T002, design `design/P3-T002-authentication.md`, ADR 0046) server authentication: `Role` (viewer < operator < admin), `Credentials` / `Authenticated` / `AuthFailure` (Spark's two `UNAUTHENTICATED` messages), the `Authenticator` trait and `ServerAuth` (throttle, then static tokens; one `krishiv::auth` warn record and one `(surface, method)` counter per failure, never the credential); `config.rs` `AuthConfig::from_settings` (`krishiv.auth.tokens..{secret,principal,kind,role}`, `spark.connect.authenticate.token` = one admin token named `spark`, `krishiv.auth.failureThrottle.*`; unknown `krishiv.auth.*` keys refused); `secret_ref.rs` `env:`/`file:` references (a file readable by group or others refuses startup); `tokens.rs` SHA-256 digests compared in constant time against every entry (secrets >= 32 bytes, no two tokens share one); `throttle.rs` per-address failure blocks, bounded at 65,536 addresses; `roles.rs` `RolePolicy`, the ADR 0046 §3 matrix as an `AccessPolicy` (viewer: `Browse`, own `system.*` rows, `/metrics`; operator: `Select`, `Usage`, `Monitor`, `Operate`; admin: `Modify`/`Create`/`Drop`/`Manage`; client code at `krishiv.auth.udf.minRole`; `Impersonate` never; no role denies all), `tests/roles_matrix.rs`. Metadata statements need `Browse` (`authorize` falls back to `Select` for providers that know only `Select`); `ADMIN`-class statements need `Manage` on `System("cluster")` - `src/partitions.rs` partitioned and bucketed tables: the layout `CREATE TABLE` stores (partition columns last), the partitions the catalog tracks (`ADD PARTITION`, `SET LOCATION` and static `INSERT` values cast to the column types, `DROP PARTITION` of a managed table: log first, then the directories; each partitioned table has a read/write lock (`InMemoryCatalogStore::partition_ddl` / `partition_write`, keyed by location, weak entries): `RENAME`/`DROP PARTITION`, `MSCK REPAIR` and `TRUNCATE` hold it exclusively (across their log commit retries) and re-read the entry under it, an `INSERT` holds it shared from its write to its partition registration; external tables refused, registered after `INSERT`/CTAS), `DESCRIBE ... PARTITION`, `SHOW PARTITIONS`, `ANALYZE ... PARTITION`; `tests/partitioned_tables.rs` - `src/column_stats.rs` `ANALYZE TABLE .. FOR COLUMNS` (`CommandUtils.statExprs`): one aggregate over the table, kept in `CatalogTable::column_stats` (the catalog is in memory, no on-disk format), printed by `DESCRIBE EXTENDED t col`; a write, `TRUNCATE`, `ADD PARTITION`, `DROP PARTITION` or `SET LOCATION` drops them (`updateTableStats`); published only to the entry the aggregate read (`CatalogTable::incarnation`, set by the store at create, kept by `replace_table`), so a table dropped and created again meanwhile gets none, nor one whose data changed after it was read (`CatalogTable::data_version`, moved by `data_changed`, which every stats-clearing change calls); `MSCK REPAIR` keeps them, as Spark 4.2.0 does; `tests/char_varchar_write.rs` - `src/table_commands/files.rs` a managed table's directory: `authorize_path` (the policy per privilege), `deletable_dir` (inside the root it was created under, no links, `MODIFY`), `delete_table_files`, `remove_dir` diff --git a/crates/krishiv-session/src/access.rs b/crates/krishiv-session/src/access.rs index d802576d8..cb6251451 100644 --- a/crates/krishiv-session/src/access.rs +++ b/crates/krishiv-session/src/access.rs @@ -174,7 +174,7 @@ fn log_denial(session: &Session, class: StatementClass, e: &KrishivError) { impl Session { /// Checks what `statement` (of `class`) writes, creates or drops, and /// what it reads without a query, before it runs; an `ADMIN` statement - /// needs `Monitor` on the cluster. Names are located as analysis locates + /// needs `Manage` on the cluster (ADR 0046 §4). Names are located as analysis locates /// them and asked about as written and, unless analysis is /// case-sensitive, folded; paths in their qualified form. pub(crate) async fn authorize_statement( @@ -188,12 +188,16 @@ impl Session { return Err(e); } let mut targets = statement_targets(statement); + // Every cluster-scope statement needs `Manage` on the cluster (ADR 0046 §4), whatever + // else it names. if class == StatementClass::Admin - && !targets.iter().any(|t| t.object == TargetObject::Cluster) + && !targets + .iter() + .any(|t| t.object == TargetObject::Cluster && t.privilege == Privilege::Manage) { targets.push(Target { object: TargetObject::Cluster, - privilege: Privilege::Monitor, + privilege: Privilege::Manage, }); } let mut checked: Vec<(Securable, Privilege)> = Vec::new(); @@ -284,13 +288,14 @@ impl Session { Ok(views) } - /// `SELECT` on the permanent view `namespace.name`: the statements that - /// show a view's definition, columns or properties need it as a query of - /// the view does, besides the table-named check made before dispatch. - pub(crate) async fn authorize_view_select( + /// `privilege` on the permanent view `namespace.name`, besides the table-named check made + /// before dispatch: the statements that show a view's definition, columns or properties + /// need `Browse` on it (ADR 0046 §4), `SHOW TABLE EXTENDED` `Select`. + pub(crate) async fn authorize_view( &self, namespace: &str, name: &str, + privilege: Privilege, env: &QueryEnv, ) -> Result<(), KrishivError> { let parts = [ @@ -304,7 +309,7 @@ impl Session { &*self.state.policy, &self.state.principal, &Securable::View(ident), - Privilege::Select, + privilege, ) .await; audit(self, StatementClass::Read, r) diff --git a/crates/krishiv-session/src/auth/mod.rs b/crates/krishiv-session/src/auth/mod.rs index 89f5f979d..ea5c4b5ba 100644 --- a/crates/krishiv-session/src/auth/mod.rs +++ b/crates/krishiv-session/src/auth/mod.rs @@ -18,6 +18,7 @@ //! credential) and its failure counters. mod config; +mod roles; mod secret_ref; mod server; mod throttle; @@ -33,6 +34,7 @@ use futures::future::BoxFuture; use krishiv_common::principal::Principal; pub use config::{AuthConfig, ThrottleConfig, TokenSpec, is_auth_key}; +pub use roles::RolePolicy; pub use secret_ref::SecretRef; pub use server::{FailureCount, ServerAuth}; pub use throttle::FailureThrottle; diff --git a/crates/krishiv-session/src/auth/roles.rs b/crates/krishiv-session/src/auth/roles.rs new file mode 100644 index 000000000..26086aed4 --- /dev/null +++ b/crates/krishiv-session/src/auth/roles.rs @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: Apache-2.0 +//! [`RolePolicy`]: the ADR 0046 role matrix as an [`AccessPolicy`] provider (ADR 0046 §1, §3). +//! +//! A session of an authenticated server principal gets `PathPrefixPolicy(RolePolicy(role))` +//! (design §3.6). There is no role check anywhere else: every surface asks the one +//! `authorize` path, so a denial has one error class, one log record and one counter. +//! +//! What each role may do, by privilege (the securable decides only where the ADR says so): +//! +//! | Privilege | viewer | operator | admin | +//! |---|---|---|---| +//! | `Browse` (metadata) | yes | yes | yes | +//! | `Select` on a `system.*` table (own rows; others' need `Monitor`) | yes | yes | yes | +//! | `Select` on anything else (tables, views, functions, paths) | no (T062 grants) | yes | yes | +//! | `Usage` on a secret or connection | no | yes | yes | +//! | `Usage` on `System("udf")` (client code) | `udf_min_role` | `udf_min_role` | yes | +//! | `Monitor` on `System("metrics")` | yes | yes | yes | +//! | `Monitor` elsewhere (others' rows in `system.*`) | no | yes | yes | +//! | `Operate` (others' queries and sessions) | no | yes | yes | +//! | `Modify`, `Create`, `Drop` | no | no | yes | +//! | `Manage` (cluster-scope changes) | no | no | yes | +//! | `Impersonate` | never part of a role (§5) | | | +//! +//! A principal with no role is denied everything (§2). + +use futures::future::BoxFuture; +use krishiv_common::error::KrishivError; +use krishiv_common::principal::Principal; +use krishiv_plan::analyzer::{AccessDecision, AccessPolicy, PolicyVersion, Privilege, Securable}; + +use super::Role; + +/// The fixed `System(..)` names (ADR 0046 §4). +pub(crate) const SYSTEM_UDF: &str = "udf"; +/// `/metrics` scrapes. +pub(crate) const SYSTEM_METRICS: &str = "metrics"; +/// The reserved `system` catalog. +const SYSTEM_CATALOG: &str = "system"; + +/// The ADR 0046 role matrix for one session's principal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RolePolicy { + role: Option, + udf_min_role: Role, + version: PolicyVersion, +} + +impl RolePolicy { + /// The policy of a principal with `role` (`None`: no role, everything denied). Client + /// code needs `udf_min_role` or above (`krishiv.auth.udf.minRole`; `Admin` until the + /// T014b sandbox merges, ADR 0046 N1). `version` changes whenever the role mapping is + /// reloaded, so plan-cache keys cannot carry an old decision. + #[must_use] + pub fn new(role: Option, udf_min_role: Role, version: PolicyVersion) -> Self { + Self { + role, + udf_min_role, + version, + } + } + + /// The role, if any. + #[must_use] + pub fn role(&self) -> Option { + self.role + } + + /// The least role that holds `privilege` on `securable`; `None` when no role does. + #[must_use] + pub fn required(&self, securable: &Securable, privilege: Privilege) -> Option { + required(securable, privilege, self.udf_min_role) + } + + /// Whether this policy's role holds `privilege` on `securable`. + #[must_use] + pub fn allows(&self, securable: &Securable, privilege: Privilege) -> bool { + match (self.role, self.required(securable, privilege)) { + (Some(have), Some(need)) => have >= need, + _ => false, + } + } +} + +/// The matrix (see the module docs). +fn required(securable: &Securable, privilege: Privilege, udf_min_role: Role) -> Option { + let system = |name: &str| matches!(securable, Securable::System(n) if n == name); + match privilege { + Privilege::Browse => Some(Role::Viewer), + Privilege::Select => match securable { + Securable::Table(t) | Securable::View(t) if t.catalog == SYSTEM_CATALOG => { + Some(Role::Viewer) + } + _ => Some(Role::Operator), + }, + Privilege::Usage if system(SYSTEM_UDF) => Some(udf_min_role.max(Role::Viewer)), + Privilege::Monitor if system(SYSTEM_METRICS) => Some(Role::Viewer), + // Secrets and connections; others' rows in `system.*`; others' running work. + Privilege::Usage | Privilege::Monitor | Privilege::Operate => Some(Role::Operator), + Privilege::Modify | Privilege::Create | Privilege::Drop | Privilege::Manage => { + Some(Role::Admin) + } + // `Impersonate` is never implied by a role (§5; granted per service principal by + // T061), and a privilege added later is denied until the matrix names it. + _ => None, + } +} + +impl AccessPolicy for RolePolicy { + fn version(&self) -> PolicyVersion { + self.version + } + + fn check<'a>( + &'a self, + _principal: &'a Principal, + securable: &'a Securable, + privilege: Privilege, + ) -> BoxFuture<'a, Result> { + let decision = if self.allows(securable, privilege) { + AccessDecision::Allow + } else { + AccessDecision::Deny { + reason: match self.role { + Some(role) => format!("role {role} does not hold {privilege}"), + None => "the principal has no role".to_owned(), + }, + } + }; + Box::pin(std::future::ready(Ok(decision))) + } +} diff --git a/crates/krishiv-session/src/auth/tests.rs b/crates/krishiv-session/src/auth/tests.rs index 2a27a81c3..f23794a81 100644 --- a/crates/krishiv-session/src/auth/tests.rs +++ b/crates/krishiv-session/src/auth/tests.rs @@ -580,3 +580,81 @@ fn failure_logs_never_contain_the_token() { assert!(!logged.contains(secret), "{logged}"); } } + +mod roles { + use krishiv_plan::analyzer::{PolicyVersion, Privilege, Securable, TableIdent}; + + use super::super::{Role, RolePolicy}; + + fn table(catalog: &str) -> Securable { + Securable::Table(TableIdent { + catalog: catalog.to_owned(), + namespace: vec!["default".to_owned()], + name: "t".to_owned(), + }) + } + + fn sys(name: &str) -> Securable { + Securable::System(name.to_owned()) + } + + #[test] + fn required_roles_follow_adr_0046() { + let p = RolePolicy::new(Some(Role::Viewer), Role::Admin, PolicyVersion(1)); + let cases = [ + ( + table("spark_catalog"), + Privilege::Browse, + Some(Role::Viewer), + ), + ( + table("spark_catalog"), + Privilege::Select, + Some(Role::Operator), + ), + (table("system"), Privilege::Select, Some(Role::Viewer)), + (sys("udf"), Privilege::Usage, Some(Role::Admin)), + ( + Securable::Secret { + scope: "s".to_owned(), + }, + Privilege::Usage, + Some(Role::Operator), + ), + (sys("metrics"), Privilege::Monitor, Some(Role::Viewer)), + (sys("system"), Privilege::Monitor, Some(Role::Operator)), + (sys("queries"), Privilege::Operate, Some(Role::Operator)), + (sys("sessions"), Privilege::Operate, Some(Role::Operator)), + (table("spark_catalog"), Privilege::Modify, Some(Role::Admin)), + (table("spark_catalog"), Privilege::Create, Some(Role::Admin)), + (table("spark_catalog"), Privilege::Drop, Some(Role::Admin)), + (sys("cluster"), Privilege::Manage, Some(Role::Admin)), + (sys("principals"), Privilege::Impersonate, None), + ]; + for (securable, privilege, need) in cases { + assert_eq!( + p.required(&securable, privilege), + need, + "{privilege} on {securable}" + ); + } + } + + #[test] + fn udf_min_role_lowers_client_code_only() { + let p = RolePolicy::new(Some(Role::Operator), Role::Operator, PolicyVersion(1)); + assert!(p.allows(&sys("udf"), Privilege::Usage)); + let strict = RolePolicy::new(Some(Role::Operator), Role::Admin, PolicyVersion(1)); + assert!(!strict.allows(&sys("udf"), Privilege::Usage)); + assert!(!p.allows(&sys("cluster"), Privilege::Manage)); + } + + #[test] + fn admin_never_impersonates_and_no_role_holds_nothing() { + let admin = RolePolicy::new(Some(Role::Admin), Role::Admin, PolicyVersion(1)); + assert!(!admin.allows(&sys("principals"), Privilege::Impersonate)); + let none = RolePolicy::new(None, Role::Admin, PolicyVersion(1)); + assert!(!none.allows(&table("spark_catalog"), Privilege::Browse)); + assert_eq!(none.role(), None); + } +} diff --git a/crates/krishiv-session/src/introspection.rs b/crates/krishiv-session/src/introspection.rs index ccd51da9e..5bf55b33d 100644 --- a/crates/krishiv-session/src/introspection.rs +++ b/crates/krishiv-session/src/introspection.rs @@ -10,6 +10,7 @@ use krishiv_common::error::{ ExpectPermanentViewNotTemp, KrishivError, KrishivUnsupportedFeature, SchemaNotFound, ShowColumnsWithConflictNamespace, UnresolvedColumnWithSuggestion, }; +use krishiv_plan::analyzer::Privilege; use krishiv_sql::ast::{ DdlName, Describe, DescribeKind, PartitionSpec, Show, ShowKind, ShowPattern, }; @@ -605,7 +606,7 @@ impl Session { } let table = self.stored_relation(&name)?; if table.view.is_some() { - self.authorize_view_select(&name.namespace, &name.name, &env) + self.authorize_view(&name.namespace, &name.name, Privilege::Browse, &env) .await?; } let rows = match key { @@ -647,7 +648,7 @@ impl Session { return Err(unsupported("SHOW CREATE TABLE ... AS SERDE on a table")); } if table.view.is_some() { - self.authorize_view_select(&name.namespace, &name.name, &env) + self.authorize_view(&name.namespace, &name.name, Privilege::Browse, &env) .await?; } self.ordered_frame( @@ -746,7 +747,7 @@ impl Session { } if t.view.is_some() { // The information holds the view's text, as SHOW CREATE TABLE does. - self.authorize_view_select(&ns, &t.name, &self.query_env()) + self.authorize_view(&ns, &t.name, Privilege::Select, &self.query_env()) .await?; } let mut entries = self.table_information(&t); @@ -910,7 +911,7 @@ impl Session { } let table = self.stored_relation(name)?; if table.view.is_some() { - self.authorize_view_select(&name.namespace, &name.name, &env) + self.authorize_view(&name.namespace, &name.name, Privilege::Browse, &env) .await?; } Ok(table diff --git a/crates/krishiv-session/tests/access_policy.rs b/crates/krishiv-session/tests/access_policy.rs index 6aa1c969a..27d80c28e 100644 --- a/crates/krishiv-session/tests/access_policy.rs +++ b/crates/krishiv-session/tests/access_policy.rs @@ -473,7 +473,7 @@ fn names_are_checked_as_written_and_folded() { ); assert_eq!( asked("SHOW TABLES"), - ["SELECT namespace `spark_catalog`.`default`"] + ["BROWSE namespace `spark_catalog`.`default`"] ); assert_eq!( asked("CREATE GLOBAL TEMPORARY VIEW g AS SELECT 1"), @@ -487,7 +487,7 @@ fn names_are_checked_as_written_and_folded() { asked("REFRESH TABLE t"), [ "SELECT table `spark_catalog`.`default`.`t`", - "MONITOR system `cluster`" + "MANAGE system `cluster`" ] ); assert_eq!(asked("SET a = b"), Vec::::new()); @@ -1164,8 +1164,10 @@ fn a_stored_function_cannot_read_a_temporary_table() { static DENY_VIEWS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); fn deny_view_selects(securable: &Securable, p: Privilege) -> AccessDecision { + // Metadata of a view needs `Browse` on it, its data and statistics `Select` (ADR 0046 §4): + // denying both hides it from every statement below. if DENY_VIEWS.load(std::sync::atomic::Ordering::SeqCst) - && p == Privilege::Select + && matches!(p, Privilege::Select | Privilege::Browse) && matches!(securable, Securable::View(_)) { deny() diff --git a/crates/krishiv-session/tests/roles_matrix.rs b/crates/krishiv-session/tests/roles_matrix.rs new file mode 100644 index 000000000..964818b63 --- /dev/null +++ b/crates/krishiv-session/tests/roles_matrix.rs @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 +//! `roles_matrix` (P3-T002 acceptance; ADR 0046 §3): viewer, operator and admin sessions over +//! the statement classes that exist when T002 lands, through the one `authorize` path. +//! +//! A viewer sees metadata and runs plans that read no data; an operator also reads data; only +//! an admin writes, runs DDL or cluster-scope statements. A principal without a role is denied +//! even metadata. +#![cfg(test)] + +use std::sync::Arc; + +use futures::executor::block_on; +use krishiv_plan::analyzer::PolicyVersion; +use krishiv_session::auth::{Role, RolePolicy}; +use krishiv_session::{EngineRuntime, Principal, PrincipalKind, Session}; + +const DENIED: &str = "KRISHIV_INSUFFICIENT_PERMISSIONS"; + +/// One runtime (shared catalog and warehouse) with table `default.t` created by an admin. +fn runtime(dir: &std::path::Path) -> Arc { + let rt = EngineRuntime::builder() + .config("spark.sql.warehouse.dir", dir.to_str().unwrap()) + .build() + .unwrap(); + let admin = session(&rt, Some(Role::Admin)); + run(&admin, "CREATE TABLE t (a INT) USING parquet").unwrap(); + run(&admin, "INSERT INTO t VALUES (1), (2)").unwrap(); + rt +} + +fn session(rt: &Arc, role: Option) -> Session { + let name = role.map_or("nobody", Role::as_str); + Session::builder() + .runtime(Arc::clone(rt)) + .principal(Principal::new(name, PrincipalKind::User)) + .access_policy(Arc::new(RolePolicy::new( + role, + Role::Admin, + PolicyVersion(1), + ))) + .build() + .unwrap() +} + +/// Runs `sql` to completion; the error text on failure. +fn run(s: &Session, sql: &str) -> Result<(), String> { + block_on(async { + let df = s.sql(sql).await?; + df.collect().await.map(|_| ()) + }) + .map_err(|e| e.to_string()) +} + +/// The statements and the least role each needs. +const MATRIX: &[(&str, Role)] = &[ + // Plans that read no data securable. + ("SELECT 1", Role::Viewer), + ("SELECT * FROM VALUES (1), (2)", Role::Viewer), + ("SELECT id FROM range(3)", Role::Viewer), + // Session class. + ("SET spark.sql.shuffle.partitions = 4", Role::Viewer), + ("USE default", Role::Viewer), + // Metadata (Browse). + ("SHOW TABLES", Role::Viewer), + ("SHOW COLUMNS IN t", Role::Viewer), + ("DESCRIBE t", Role::Viewer), + ("SHOW CREATE TABLE t", Role::Viewer), + ("SHOW TBLPROPERTIES t", Role::Viewer), + ("SHOW DATABASES", Role::Viewer), + // Own rows of system tables. + ("SELECT count(*) FROM system.queries", Role::Viewer), + // Reads of data and statistics. + ("SELECT * FROM t", Role::Operator), + ("SELECT count(*) FROM t", Role::Operator), + ("DESCRIBE EXTENDED t", Role::Operator), + ("EXPLAIN ANALYZE SELECT * FROM t", Role::Operator), + // Durable writes and DDL. + ("INSERT INTO t VALUES (3)", Role::Admin), + ("CREATE TABLE u (b INT) USING parquet", Role::Admin), + ("CREATE DATABASE d2", Role::Admin), + // Cluster scope. + ("CLEAR CACHE", Role::Admin), +]; + +#[test] +fn roles_matrix() { + let dir = tempfile::tempdir().unwrap(); + let rt = runtime(dir.path()); + let mut failures = Vec::new(); + for role in [Role::Viewer, Role::Operator, Role::Admin] { + for (sql, need) in MATRIX { + // A fresh session per statement, so a write by one row never changes another. + let s = session(&rt, Some(role)); + let r = run(&s, sql); + let allowed = role >= *need; + match (&r, allowed) { + (Ok(()), true) => {} + (Err(e), false) if e.contains(DENIED) => {} + _ => failures.push(format!("{role} `{sql}` (needs {need}): {r:?}")), + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn no_role_is_denied_even_metadata() { + let dir = tempfile::tempdir().unwrap(); + let rt = runtime(dir.path()); + let s = session(&rt, None); + let e = run(&s, "SHOW TABLES").unwrap_err(); + assert!(e.contains(DENIED), "{e}"); + // A plan that names no securable needs no privilege (ADR 0046 §3, row 3). + run(&s, "SELECT 1").unwrap(); +} + +#[test] +fn a_denial_reveals_no_reason_or_existence() { + let dir = tempfile::tempdir().unwrap(); + let rt = runtime(dir.path()); + let s = session(&rt, Some(Role::Viewer)); + let existing = run(&s, "SELECT * FROM t").unwrap_err(); + assert!( + existing.contains(DENIED) && !existing.contains("role"), + "{existing}" + ); + let missing = run(&s, "SELECT * FROM no_such_table").unwrap_err(); + assert!(missing.contains(DENIED), "{missing}"); +} diff --git a/crates/krishiv-sql/src/classify.rs b/crates/krishiv-sql/src/classify.rs index 95c58f92c..bb3cb899b 100644 --- a/crates/krishiv-sql/src/classify.rs +++ b/crates/krishiv-sql/src/classify.rs @@ -337,15 +337,16 @@ pub struct Target<'a> { /// checks): `Modify` on what it writes, `Create` on the namespace a new /// object goes in, `Drop` on what it drops or replaces (an `ALTER` replaces /// the object's definition: `Drop` on it and `Create` in its namespace), -/// `Select` on what it reads without a query (metadata commands, `LIKE` -/// sources, `MERGE` from a table), and `Monitor` on the cluster for the -/// `ADMIN` class. Session-class statements need nothing beyond the files +/// `Browse` on what a metadata command only names (`SHOW TABLES`, `SHOW COLUMNS`, plain +/// `DESCRIBE`; ADR 0046 §4), `Select` on what it reads without a query (statistics and +/// partition values, `LIKE` sources, `MERGE` from a table), and `Manage` on the cluster for +/// the `ADMIN` class (ADR 0046 §4). Session-class statements need nothing beyond the files /// they name (a temporary table's `LOCATION`). #[must_use] #[deny(clippy::wildcard_enum_match_arm)] #[expect(clippy::too_many_lines, reason = "one arm per statement kind")] pub fn statement_targets(statement: &Statement) -> Vec> { - use Privilege::{Create, Drop, Modify, Monitor, Select}; + use Privilege::{Browse, Create, Drop, Manage, Modify, Select}; use TargetObject as O; const fn t(object: TargetObject<'_>, privilege: Privilege) -> Target<'_> { Target { object, privilege } @@ -363,7 +364,7 @@ pub fn statement_targets(statement: &Statement) -> Vec> { let object = namespace .as_ref() .map_or(TargetObject::CurrentNamespace, TargetObject::Namespace); - vec![t(object, Privilege::Select)] + vec![t(object, Privilege::Browse)] } if let Statement::Script(c) = statement { // What any statement of the script writes, creates or drops, asked up front. @@ -527,26 +528,38 @@ pub fn statement_targets(statement: &Statement) -> Vec> { Statement::Analyze(a) => vec![t(O::Table(&a.table), Select), t(O::Table(&a.table), Modify)], Statement::Show(s) => match &s.kind { ShowKind::Tables { namespace, .. } - | ShowKind::TableExtended { namespace, .. } | ShowKind::Views { namespace, .. } | ShowKind::Functions { namespace, .. } => listing(namespace), + // Prints statistics and partition values, which are data (ADR 0046 §3). + ShowKind::TableExtended { namespace, .. } => { + let object = namespace.as_ref().map_or(O::CurrentNamespace, O::Namespace); + vec![t(object, Select)] + } ShowKind::Columns { table, namespace, .. } => vec![t( namespace .as_ref() .map_or(O::Table(table), |namespace| O::TableIn { table, namespace }), - Select, + Browse, )], - ShowKind::Namespaces { .. } => vec![t(O::CurrentCatalog, Select)], - ShowKind::CreateTable { name, .. } - | ShowKind::TblProperties { name, .. } - | ShowKind::Partitions { name, .. } => vec![t(O::Table(name), Select)], + ShowKind::Namespaces { .. } => vec![t(O::CurrentCatalog, Browse)], + ShowKind::CreateTable { name, .. } | ShowKind::TblProperties { name, .. } => { + vec![t(O::Table(name), Browse)] + } + // Partition values are data. + ShowKind::Partitions { name, .. } => vec![t(O::Table(name), Select)], ShowKind::CurrentNamespace { .. } | ShowKind::Catalogs { .. } => Vec::new(), }, Statement::Describe(d) => match &d.kind { - DescribeKind::Table { table, .. } => vec![t(O::Table(table), Select)], - DescribeKind::Namespace { name, .. } => vec![t(O::Namespace(name), Select)], + // `EXTENDED`/`FORMATTED` print statistics (a column's min and max), which are data. + DescribeKind::Table { table, mode, .. } => { + vec![t( + O::Table(table), + if mode.is_some() { Select } else { Browse }, + )] + } + DescribeKind::Namespace { name, .. } => vec![t(O::Namespace(name), Browse)], // Built-in functions are public; a query's reads are analyzed. DescribeKind::Function { .. } | DescribeKind::Query { .. } => Vec::new(), }, @@ -561,15 +574,15 @@ pub fn statement_targets(statement: &Statement) -> Vec> { } // Whether the table exists is answered, so naming it needs the right to read it. CacheKind::Uncache { name, .. } => vec![t(O::Table(name), Select)], - CacheKind::Clear => vec![t(O::Cluster, Monitor)], + CacheKind::Clear => vec![t(O::Cluster, Manage)], }, Statement::Refresh(r) => match &r.kind { RefreshKind::Table { name, .. } => { - vec![t(O::Table(name), Select), t(O::Cluster, Monitor)] + vec![t(O::Table(name), Select), t(O::Cluster, Manage)] } - RefreshKind::Function { .. } | RefreshKind::Path { .. } => vec![t(O::Cluster, Monitor)], + RefreshKind::Function { .. } | RefreshKind::Path { .. } => vec![t(O::Cluster, Manage)], }, - Statement::AddResource(_) => vec![t(O::Cluster, Monitor)], + Statement::AddResource(_) => vec![t(O::Cluster, Manage)], } } From 288129eda3c99ae2a9b6ad0e35e6b30c2bfa1a20 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:03:18 +0100 Subject: [PATCH 05/47] session: public-api snapshot for RolePolicy and the new privileges Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-session/public-api.txt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/krishiv-session/public-api.txt b/crates/krishiv-session/public-api.txt index 5c48f670e..d6e66d414 100644 --- a/crates/krishiv-session/public-api.txt +++ b/crates/krishiv-session/public-api.txt @@ -294,6 +294,24 @@ pub fn krishiv_session::auth::FailureThrottle::new(krishiv_session::auth::Thrott pub fn krishiv_session::auth::FailureThrottle::record_failure(&self, core::net::ip_addr::IpAddr, std::time::Instant) -> bool impl core::fmt::Debug for krishiv_session::auth::FailureThrottle pub fn krishiv_session::auth::FailureThrottle::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct krishiv_session::auth::RolePolicy +impl krishiv_session::auth::RolePolicy +pub fn krishiv_session::auth::RolePolicy::allows(&self, &krishiv_plan::analyzer::catalog::Securable, krishiv_plan::analyzer::catalog::Privilege) -> bool +pub fn krishiv_session::auth::RolePolicy::new(core::option::Option, krishiv_session::auth::Role, krishiv_plan::analyzer::catalog::PolicyVersion) -> Self +pub fn krishiv_session::auth::RolePolicy::required(&self, &krishiv_plan::analyzer::catalog::Securable, krishiv_plan::analyzer::catalog::Privilege) -> core::option::Option +pub fn krishiv_session::auth::RolePolicy::role(&self) -> core::option::Option +impl core::clone::Clone for krishiv_session::auth::RolePolicy +pub fn krishiv_session::auth::RolePolicy::clone(&self) -> krishiv_session::auth::RolePolicy +impl core::cmp::Eq for krishiv_session::auth::RolePolicy +impl core::cmp::PartialEq for krishiv_session::auth::RolePolicy +pub fn krishiv_session::auth::RolePolicy::eq(&self, &krishiv_session::auth::RolePolicy) -> bool +impl core::fmt::Debug for krishiv_session::auth::RolePolicy +pub fn krishiv_session::auth::RolePolicy::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Copy for krishiv_session::auth::RolePolicy +impl core::marker::StructuralPartialEq for krishiv_session::auth::RolePolicy +impl krishiv_plan::analyzer::catalog::AccessPolicy for krishiv_session::auth::RolePolicy +pub fn krishiv_session::auth::RolePolicy::check<'a>(&'a self, &'a krishiv_common::principal::Principal, &'a krishiv_plan::analyzer::catalog::Securable, krishiv_plan::analyzer::catalog::Privilege) -> futures_core::future::BoxFuture<'a, core::result::Result> +pub fn krishiv_session::auth::RolePolicy::version(&self) -> krishiv_plan::analyzer::catalog::PolicyVersion pub struct krishiv_session::auth::ServerAuth impl krishiv_session::auth::ServerAuth pub fn krishiv_session::auth::ServerAuth::authenticate_at(&self, &krishiv_session::auth::Credentials<'_>, std::time::Instant) -> core::result::Result From 28875e639f1e348fac2df910f3d837de969a8984 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:16:13 +0100 Subject: [PATCH 06/47] plan, sql, session: roles review fixes (view text and partitions need Select, viewer system tables, AllowWith browses) From the Opus review of p3/t002-roles: - SHOW CREATE TABLE of a view and DESCRIBE with EXTENDED/FORMATTED or a PARTITION spec need Select (the view check follows the statement); - a viewer selects only system.queries, capabilities and metrics, names compared ignoring case; - AllowWith satisfies Browse, directly and on its Select fallback; - tests: select_implies_browse, a_filtered_reader_may_browse, the view Browse/Select split, roles_matrix rows for views, cache, refresh, system tables and path reads. The stricter readings are recorded in ADR 0046's 2026-11-12 update. Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-plan/src/analyzer/access.rs | 78 +++++++++++++++++-- crates/krishiv-session/src/auth/roles.rs | 14 +++- crates/krishiv-session/src/auth/tests.rs | 30 ++++++- crates/krishiv-session/src/introspection.rs | 18 +++-- crates/krishiv-session/tests/access_policy.rs | 41 ++++++++++ crates/krishiv-session/tests/roles_matrix.rs | 31 +++++++- crates/krishiv-sql/src/classify.rs | 16 ++-- 7 files changed, 207 insertions(+), 21 deletions(-) diff --git a/crates/krishiv-plan/src/analyzer/access.rs b/crates/krishiv-plan/src/analyzer/access.rs index 07de51a64..c450ea6e1 100644 --- a/crates/krishiv-plan/src/analyzer/access.rs +++ b/crates/krishiv-plan/src/analyzer/access.rs @@ -58,12 +58,19 @@ pub async fn authorize( } } let decision = match policy.check(principal, securable, privilege).await { + Ok(AccessDecision::Allow) => Ok(AccessDecision::Allow), + // Metadata is not rows: a row filter or column mask does not hide that the object + // exists or its schema. + Ok(AccessDecision::AllowWith(_)) if privilege == Privilege::Browse => { + Ok(AccessDecision::Allow) + } // `Select` implies `Browse` (ADR 0046 §4): a provider that knows only `Select` keeps // answering metadata commands as it did before `Browse` existed. - Ok(AccessDecision::Allow) => Ok(AccessDecision::Allow), Ok(_) | Err(_) if privilege == Privilege::Browse => { match policy.check(principal, securable, Privilege::Select).await { - Ok(AccessDecision::Allow) => Ok(AccessDecision::Allow), + Ok(AccessDecision::Allow | AccessDecision::AllowWith(_)) => { + Ok(AccessDecision::Allow) + } _ => return Err(denied()), } } @@ -211,15 +218,63 @@ mod tests { Fixed(answers.iter().cloned().collect()) } - const ALL: [Privilege; 6] = [ + const ALL: [Privilege; 10] = [ Privilege::Select, Privilege::Modify, Privilege::Create, Privilege::Drop, Privilege::Monitor, Privilege::Usage, + Privilege::Browse, + Privilege::Operate, + Privilege::Manage, + Privilege::Impersonate, ]; + #[test] + fn select_implies_browse() { + // A provider that knows only `Select` answers metadata as before `Browse` existed. + let select_only = fixed(&[ + (Privilege::Select, Some(AccessDecision::Allow)), + (Privilege::Browse, deny()), + ]); + check(&select_only, Privilege::Browse).unwrap(); + // A provider that fails on `Browse` but allows `Select` too. + let browse_errors = fixed(&[ + (Privilege::Select, Some(AccessDecision::Allow)), + (Privilege::Browse, None), + ]); + check(&browse_errors, Privilege::Browse).unwrap(); + // `Browse` alone does not give `Select`. + let browse_only = fixed(&[ + (Privilege::Browse, Some(AccessDecision::Allow)), + (Privilege::Select, deny()), + ]); + check(&browse_only, Privilege::Browse).unwrap(); + let e = check(&browse_only, Privilege::Select).unwrap_err(); + assert_eq!(e.error_class(), ErrorClass::KrishivInsufficientPermissions); + // Neither: denied, as `BROWSE`, without the reason. + let neither = fixed(&[(Privilege::Select, deny()), (Privilege::Browse, deny())]); + let e = check(&neither, Privilege::Browse).unwrap_err(); + assert_eq!(e.error_class(), ErrorClass::KrishivInsufficientPermissions); + assert!( + e.to_string().contains("BROWSE") && !e.to_string().contains("secret"), + "{e}" + ); + } + + #[test] + fn a_filtered_reader_may_browse() { + // Metadata is not rows: a row filter answered for `Browse` or for its `Select` + // fallback still shows that the object exists and its schema. + for policy in [ + fixed(&[(Privilege::Browse, filtered()), (Privilege::Select, deny())]), + fixed(&[(Privilege::Browse, deny()), (Privilege::Select, filtered())]), + ] { + check(&policy, Privilege::Browse).unwrap(); + } + } + #[test] fn allow_all_and_deny_all() { for p in ALL { @@ -319,11 +374,24 @@ mod tests { let names: Vec = ALL.iter().map(ToString::to_string).collect(); assert_eq!( names, - ["SELECT", "MODIFY", "CREATE", "DROP", "MONITOR", "USAGE"] + [ + "SELECT", + "MODIFY", + "CREATE", + "DROP", + "MONITOR", + "USAGE", + "BROWSE", + "OPERATE", + "MANAGE", + "IMPERSONATE" + ] ); assert_eq!( ALL.map(Privilege::changes), - [false, true, true, true, false, false] + [ + false, true, true, true, false, false, false, false, false, false + ] ); } } diff --git a/crates/krishiv-session/src/auth/roles.rs b/crates/krishiv-session/src/auth/roles.rs index 26086aed4..89b8968cf 100644 --- a/crates/krishiv-session/src/auth/roles.rs +++ b/crates/krishiv-session/src/auth/roles.rs @@ -10,7 +10,8 @@ //! | Privilege | viewer | operator | admin | //! |---|---|---|---| //! | `Browse` (metadata) | yes | yes | yes | -//! | `Select` on a `system.*` table (own rows; others' need `Monitor`) | yes | yes | yes | +//! | `Select` on `system.queries` (own rows; others' need `Monitor`), `capabilities`, `metrics` | yes | yes | yes | +//! | `Select` on the other `system.*` tables (`memory`, `operators`, `caches`) | no | yes | yes | //! | `Select` on anything else (tables, views, functions, paths) | no (T062 grants) | yes | yes | //! | `Usage` on a secret or connection | no | yes | yes | //! | `Usage` on `System("udf")` (client code) | `udf_min_role` | `udf_min_role` | yes | @@ -36,6 +37,10 @@ pub(crate) const SYSTEM_UDF: &str = "udf"; pub(crate) const SYSTEM_METRICS: &str = "metrics"; /// The reserved `system` catalog. const SYSTEM_CATALOG: &str = "system"; +/// The `system` tables a viewer reads: `queries` shows only the principal's own rows without +/// `Monitor`, `capabilities` and `metrics` hold no principal's data (ADR 0046 §3, §3c). The +/// others (`memory`, `operators`, `caches`) name other principals' work: operator. +const VIEWER_SYSTEM_TABLES: [&str; 3] = ["queries", "capabilities", "metrics"]; /// The ADR 0046 role matrix for one session's principal. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -87,7 +92,12 @@ fn required(securable: &Securable, privilege: Privilege, udf_min_role: Role) -> match privilege { Privilege::Browse => Some(Role::Viewer), Privilege::Select => match securable { - Securable::Table(t) | Securable::View(t) if t.catalog == SYSTEM_CATALOG => { + Securable::Table(t) + if t.catalog.eq_ignore_ascii_case(SYSTEM_CATALOG) + && VIEWER_SYSTEM_TABLES + .iter() + .any(|n| t.name.eq_ignore_ascii_case(n)) => + { Some(Role::Viewer) } _ => Some(Role::Operator), diff --git a/crates/krishiv-session/src/auth/tests.rs b/crates/krishiv-session/src/auth/tests.rs index f23794a81..449137ddf 100644 --- a/crates/krishiv-session/src/auth/tests.rs +++ b/crates/krishiv-session/src/auth/tests.rs @@ -594,6 +594,14 @@ mod roles { }) } + fn system_table(name: &str) -> Securable { + Securable::Table(TableIdent { + catalog: "SYSTEM".to_owned(), + namespace: Vec::new(), + name: name.to_owned(), + }) + } + fn sys(name: &str) -> Securable { Securable::System(name.to_owned()) } @@ -612,7 +620,27 @@ mod roles { Privilege::Select, Some(Role::Operator), ), - (table("system"), Privilege::Select, Some(Role::Viewer)), + (table("system"), Privilege::Select, Some(Role::Operator)), + ( + system_table("queries"), + Privilege::Select, + Some(Role::Viewer), + ), + ( + system_table("CAPABILITIES"), + Privilege::Select, + Some(Role::Viewer), + ), + ( + system_table("metrics"), + Privilege::Select, + Some(Role::Viewer), + ), + ( + system_table("memory"), + Privilege::Select, + Some(Role::Operator), + ), (sys("udf"), Privilege::Usage, Some(Role::Admin)), ( Securable::Secret { diff --git a/crates/krishiv-session/src/introspection.rs b/crates/krishiv-session/src/introspection.rs index 5bf55b33d..667b80011 100644 --- a/crates/krishiv-session/src/introspection.rs +++ b/crates/krishiv-session/src/introspection.rs @@ -582,7 +582,7 @@ impl Session { name.namespace = from; name.bare = false; } - let columns = self.columns_of_relation(&name).await?; + let columns = self.columns_of_relation(&name, Privilege::Browse).await?; let rows = columns.into_iter().map(|c| vec![s(c.0)]).collect(); self.frame_with_order(&["col_name"], &[], rows, false).await } @@ -648,7 +648,7 @@ impl Session { return Err(unsupported("SHOW CREATE TABLE ... AS SERDE on a table")); } if table.view.is_some() { - self.authorize_view(&name.namespace, &name.name, Privilege::Browse, &env) + self.authorize_view(&name.namespace, &name.name, Privilege::Select, &env) .await?; } self.ordered_frame( @@ -870,10 +870,13 @@ impl Session { .ok_or_else(|| name.not_found()) } - /// The (name, type, comment) of the columns of a table, view or temporary view. + /// The (name, type, comment) of the columns of a table, view or temporary view. A permanent + /// view is checked for `privilege`: `Browse` for its schema, `Select` when the statement also + /// shows statistics or partitions (ADR 0046 §4). async fn columns_of_relation( &self, name: &TableName, + privilege: Privilege, ) -> Result)>, KrishivError> { let env = self.query_env(); if !name.bare @@ -911,7 +914,7 @@ impl Session { } let table = self.stored_relation(name)?; if table.view.is_some() { - self.authorize_view(&name.namespace, &name.name, Privilege::Browse, &env) + self.authorize_view(&name.namespace, &name.name, privilege, &env) .await?; } Ok(table @@ -1001,7 +1004,12 @@ impl Session { .await; } let name = self.table_name(table, &env)?; - let columns = self.columns_of_relation(&name).await?; + let privilege = if mode.is_some() || partition.is_some() { + Privilege::Select + } else { + Privilege::Browse + }; + let columns = self.columns_of_relation(&name, privilege).await?; let temp = self.temp_entry_of(&name, &env) || (!name.bare && name diff --git a/crates/krishiv-session/tests/access_policy.rs b/crates/krishiv-session/tests/access_policy.rs index 27d80c28e..be2f1040b 100644 --- a/crates/krishiv-session/tests/access_policy.rs +++ b/crates/krishiv-session/tests/access_policy.rs @@ -1957,3 +1957,44 @@ fn a_bare_read_placed_by_the_path_in_a_stored_schema_is_authorized_in_that_schem .unwrap(); assert_eq!(rows(&s, "SELECT * FROM t_path").unwrap(), 1); } + +fn deny_view_rows(securable: &Securable, p: Privilege) -> AccessDecision { + if p == Privilege::Select && matches!(securable, Securable::View(_)) { + deny() + } else { + AccessDecision::Allow + } +} + +/// ADR 0046 §4: `Browse` on a view shows that it exists and its columns; its text (the query +/// and its literals), statistics and rows need `Select`. +#[test] +fn browse_on_a_view_shows_its_schema_not_its_text_or_rows() { + let dir = tempfile::tempdir().unwrap(); + let s = session(Policy::new(deny_view_rows)); + s.conf() + .set("spark.sql.warehouse.dir", dir.path().to_str().unwrap()) + .unwrap(); + for sql in [ + "CREATE TABLE pt (a INT) USING parquet", + "CREATE VIEW pv AS SELECT a, 42 AS secret_literal FROM pt", + ] { + block_on(async { s.sql(sql).await?.collect().await }).unwrap(); + } + for sql in [ + "DESCRIBE TABLE pv", + "SHOW COLUMNS IN pv", + "SHOW TBLPROPERTIES pv", + ] { + block_on(async { s.sql(sql).await?.collect().await }) + .unwrap_or_else(|e| panic!("{sql}: {e}")); + } + for sql in [ + "SHOW CREATE TABLE pv", + "DESCRIBE TABLE EXTENDED pv", + "SHOW TABLE EXTENDED LIKE 'pv'", + "SELECT * FROM pv", + ] { + assert_eq!(class(block_on(s.sql(sql))), DENIED, "{sql}"); + } +} diff --git a/crates/krishiv-session/tests/roles_matrix.rs b/crates/krishiv-session/tests/roles_matrix.rs index 964818b63..4fbdc0d14 100644 --- a/crates/krishiv-session/tests/roles_matrix.rs +++ b/crates/krishiv-session/tests/roles_matrix.rs @@ -25,6 +25,7 @@ fn runtime(dir: &std::path::Path) -> Arc { let admin = session(&rt, Some(Role::Admin)); run(&admin, "CREATE TABLE t (a INT) USING parquet").unwrap(); run(&admin, "INSERT INTO t VALUES (1), (2)").unwrap(); + run(&admin, "CREATE VIEW v AS SELECT a, 7 AS literal FROM t").unwrap(); rt } @@ -67,19 +68,34 @@ const MATRIX: &[(&str, Role)] = &[ ("SHOW CREATE TABLE t", Role::Viewer), ("SHOW TBLPROPERTIES t", Role::Viewer), ("SHOW DATABASES", Role::Viewer), - // Own rows of system tables. + // Own rows of system tables, and tables that hold no principal's data. ("SELECT count(*) FROM system.queries", Role::Viewer), + ("SELECT count(*) FROM SYSTEM.QUERIES", Role::Viewer), + ("SELECT count(*) FROM system.capabilities", Role::Viewer), + // Tables that name other principals' work. + ("SELECT count(*) FROM system.memory", Role::Operator), + ("SELECT count(*) FROM system.operators", Role::Operator), + // A view: its schema is metadata; its text, statistics and rows are data. + ("DESCRIBE v", Role::Viewer), + ("SHOW COLUMNS IN v", Role::Viewer), + ("SHOW CREATE TABLE v", Role::Operator), + ("SELECT * FROM v", Role::Operator), + ("SHOW TABLE EXTENDED LIKE 'v'", Role::Operator), // Reads of data and statistics. ("SELECT * FROM t", Role::Operator), ("SELECT count(*) FROM t", Role::Operator), ("DESCRIBE EXTENDED t", Role::Operator), ("EXPLAIN ANALYZE SELECT * FROM t", Role::Operator), + // Non-durable side effects that read the table. + ("CACHE TABLE t", Role::Operator), + ("UNCACHE TABLE t", Role::Operator), // Durable writes and DDL. ("INSERT INTO t VALUES (3)", Role::Admin), ("CREATE TABLE u (b INT) USING parquet", Role::Admin), ("CREATE DATABASE d2", Role::Admin), - // Cluster scope. + // Cluster scope (REFRESH is stricter than ADR 0046 §3's operator row; recorded in the ADR). ("CLEAR CACHE", Role::Admin), + ("REFRESH TABLE t", Role::Admin), ]; #[test] @@ -127,3 +143,14 @@ fn a_denial_reveals_no_reason_or_existence() { let missing = run(&s, "SELECT * FROM no_such_table").unwrap_err(); assert!(missing.contains(DENIED), "{missing}"); } + +#[test] +fn path_reads_need_operator() { + let dir = tempfile::tempdir().unwrap(); + let rt = runtime(dir.path()); + let path = dir.path().join("t"); + let sql = format!("SELECT * FROM parquet.`{}`", path.display()); + let viewer = run(&session(&rt, Some(Role::Viewer)), &sql).unwrap_err(); + assert!(viewer.contains(DENIED), "{viewer}"); + run(&session(&rt, Some(Role::Operator)), &sql).unwrap(); +} diff --git a/crates/krishiv-sql/src/classify.rs b/crates/krishiv-sql/src/classify.rs index bb3cb899b..9188b3245 100644 --- a/crates/krishiv-sql/src/classify.rs +++ b/crates/krishiv-sql/src/classify.rs @@ -552,12 +552,16 @@ pub fn statement_targets(statement: &Statement) -> Vec> { ShowKind::CurrentNamespace { .. } | ShowKind::Catalogs { .. } => Vec::new(), }, Statement::Describe(d) => match &d.kind { - // `EXTENDED`/`FORMATTED` print statistics (a column's min and max), which are data. - DescribeKind::Table { table, mode, .. } => { - vec![t( - O::Table(table), - if mode.is_some() { Select } else { Browse }, - )] + // `EXTENDED`/`FORMATTED` print statistics (a column's min and max), and a partition + // spec answers whether that partition exists: both are data. + DescribeKind::Table { + table, + mode, + partition, + .. + } => { + let data = mode.is_some() || partition.is_some(); + vec![t(O::Table(table), if data { Select } else { Browse })] } DescribeKind::Namespace { name, .. } => vec![t(O::Namespace(name), Browse)], // Built-in functions are public; a query's reads are analyzed. From e68672190c5902d3126cbc8fa3cee913f0e488e6 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:48:00 +0100 Subject: [PATCH 07/47] session: refuse auth secrets over 16 KiB and secret files that are not regular before opening them (Codex review) Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-session/src/auth/secret_ref.rs | 13 ++++-- crates/krishiv-session/src/auth/server.rs | 4 +- crates/krishiv-session/src/auth/tests.rs | 44 +++++++++++++++++++ crates/krishiv-session/src/auth/tokens.rs | 16 ++++++- 4 files changed, 70 insertions(+), 7 deletions(-) diff --git a/crates/krishiv-session/src/auth/secret_ref.rs b/crates/krishiv-session/src/auth/secret_ref.rs index 94850d12d..5d606edcd 100644 --- a/crates/krishiv-session/src/auth/secret_ref.rs +++ b/crates/krishiv-session/src/auth/secret_ref.rs @@ -75,11 +75,18 @@ impl SecretRef { let unreadable = |e: std::io::Error| { invalid(key, &shown, &format!("cannot be read: {}", e.kind())) }; - // One handle for the check and the read, so the file checked is the file read. - let mut file = std::fs::File::open(path).map_err(unreadable)?; + // The path is checked before it is opened, so a FIFO or device is refused + // instead of blocking startup in `open`; the handle is checked again, so the file + // checked is the file read. + check_private(key, &shown, &std::fs::metadata(path).map_err(unreadable)?)?; + let file = std::fs::File::open(path).map_err(unreadable)?; check_private(key, &shown, &file.metadata().map_err(unreadable)?)?; + // One byte past the token cap is enough to refuse an oversized secret. + let cap = super::tokens::MAX_SECRET_BYTES as u64 + 2; let mut text = String::new(); - file.read_to_string(&mut text).map_err(unreadable)?; + file.take(cap) + .read_to_string(&mut text) + .map_err(unreadable)?; if text.ends_with('\n') { text.pop(); if text.ends_with('\r') { diff --git a/crates/krishiv-session/src/auth/server.rs b/crates/krishiv-session/src/auth/server.rs index c964e6cb2..0a98ad6bf 100644 --- a/crates/krishiv-session/src/auth/server.rs +++ b/crates/krishiv-session/src/auth/server.rs @@ -18,8 +18,8 @@ use super::throttle::FailureThrottle; use super::tokens::StaticTokens; use super::{AuthFailure, Authenticated, Authenticator, Credentials, Method, Surface}; -/// The longest bearer token hashed (design §3.3's JWT cap; static tokens are far shorter). -const MAX_TOKEN_BYTES: usize = 16 * 1024; +/// The longest bearer token hashed (design §3.3's JWT cap); configured secrets share the cap. +const MAX_TOKEN_BYTES: usize = super::tokens::MAX_SECRET_BYTES; /// One failure counter (`krishiv_auth_failed_total{surface, method}`, ADR 0046 §8). #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/krishiv-session/src/auth/tests.rs b/crates/krishiv-session/src/auth/tests.rs index 2a27a81c3..fd8756845 100644 --- a/crates/krishiv-session/src/auth/tests.rs +++ b/crates/krishiv-session/src/auth/tests.rs @@ -203,6 +203,50 @@ fn a_readable_secret_file_is_refused() { assert!(e.to_string().contains("readable by group or others"), "{e}"); } +#[cfg(unix)] +#[test] +fn a_fifo_secret_is_refused_without_blocking() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("fifo"); + let made = std::process::Command::new("mkfifo") + .arg(&path) + .status() + .unwrap(); + assert!(made.success()); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + // No writer: opening the FIFO would block; the path check refuses it first. + let e = SecretRef::File(path).resolve("k").unwrap_err(); + assert!(e.to_string().contains("not a regular file"), "{e}"); +} + +#[test] +fn an_oversized_secret_refuses_startup() { + let dir = tempfile::tempdir().unwrap(); + let long = "x".repeat(16 * 1024 + 1); + let r = secret_file(dir.path(), "long", &long, 0o600); + let c = config(&[ + ("krishiv.auth.tokens.a.secret", &r), + ("krishiv.auth.tokens.a.principal", "alice"), + ("krishiv.auth.tokens.a.role", "admin"), + ]) + .unwrap(); + let e = StaticTokens::build(&c.tokens).unwrap_err(); + assert!(e.to_string().contains("at most 16384 bytes"), "{e}"); + + // The longest accepted secret is one a client can present. + let max = "y".repeat(16 * 1024); + let r = secret_file(dir.path(), "max", &format!("{max}\n"), 0o600); + let c = config(&[ + ("krishiv.auth.tokens.a.secret", &r), + ("krishiv.auth.tokens.a.principal", "alice"), + ("krishiv.auth.tokens.a.role", "admin"), + ]) + .unwrap(); + let tokens = StaticTokens::build(&c.tokens).unwrap(); + assert!(tokens.lookup(&max).is_some()); +} + #[test] fn missing_env_secret_is_refused() { let e = SecretRef::Env("KRISHIV_TEST_UNSET_AUTH_VARIABLE".to_owned()) diff --git a/crates/krishiv-session/src/auth/tokens.rs b/crates/krishiv-session/src/auth/tokens.rs index 447899f94..cb1c44e69 100644 --- a/crates/krishiv-session/src/auth/tokens.rs +++ b/crates/krishiv-session/src/auth/tokens.rs @@ -18,6 +18,10 @@ use super::secret_ref::invalid; /// The shortest secret accepted, in bytes (design §3.2; threat model T-A4). pub(crate) const MIN_SECRET_BYTES: usize = 32; +/// The longest secret accepted, in bytes: the longest bearer token the server hashes +/// (design §3.3's JWT cap), so every configured secret can be presented. +pub(crate) const MAX_SECRET_BYTES: usize = 16 * 1024; + /// One stored token. #[derive(Debug, Clone)] struct Entry { @@ -37,20 +41,28 @@ impl StaticTokens { /// /// # Errors /// `KRISHIV_INVALID_CONFIG_VALUE` when a secret cannot be resolved, is shorter than 32 - /// bytes, or is the same as another token's secret, or when two tokens give one principal + /// bytes or longer than 16 KiB, or is the same as another token's secret, or when two tokens give one principal /// different roles. pub fn build(specs: &[TokenSpec]) -> Result { let mut entries: Vec<(Entry, &str)> = Vec::with_capacity(specs.len()); for spec in specs { let key = secret_key(&spec.name); let secret: SecretString = spec.secret.resolve(&key)?; - if secret.expose_secret().len() < MIN_SECRET_BYTES { + let len = secret.expose_secret().len(); + if len < MIN_SECRET_BYTES { return Err(invalid( &key, krishiv_common::secret::REDACTED, &format!("a token must be at least {MIN_SECRET_BYTES} bytes"), )); } + if len > MAX_SECRET_BYTES { + return Err(invalid( + &key, + krishiv_common::secret::REDACTED, + &format!("a token must be at most {MAX_SECRET_BYTES} bytes"), + )); + } let digest = digest(secret.expose_secret()); drop(secret); if let Some((_, other)) = entries.iter().find(|(e, _)| e.digest == digest) { From a6c2c7b80a26b4d8ac43d6c178b211b0c0915576 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:50:22 +0100 Subject: [PATCH 08/47] session: DESCRIBE and SHOW COLUMNS of a system table need Browse, not Select (Codex review) Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-session/src/access.rs | 28 +++++++++++++++++++ crates/krishiv-session/src/introspection.rs | 29 ++++++-------------- crates/krishiv-session/src/system.rs | 5 ++-- crates/krishiv-session/tests/roles_matrix.rs | 5 ++++ 4 files changed, 44 insertions(+), 23 deletions(-) diff --git a/crates/krishiv-session/src/access.rs b/crates/krishiv-session/src/access.rs index cb6251451..5828277e4 100644 --- a/crates/krishiv-session/src/access.rs +++ b/crates/krishiv-session/src/access.rs @@ -315,6 +315,34 @@ impl Session { audit(self, StatementClass::Read, r) } + /// The columns of the system table `table` for `DESCRIBE` / `SHOW COLUMNS`: `Browse` on + /// `system.
` (ADR 0046 §3), not the `Select` a read of its rows needs. + pub(crate) async fn system_table_columns( + &self, + table: &str, + env: &QueryEnv, + ) -> Result, KrishivError> { + let parts = ["system".to_owned(), table.to_owned()]; + let ident = table_ident(&*env.analysis.catalogs, &parts) + .ok_or_else(|| unplaceable("an empty name"))?; + let r = authorize( + &*self.state.policy, + &self.state.principal, + &Securable::Table(ident), + Privilege::Browse, + ) + .await; + audit(self, StatementClass::Read, r)?; + crate::system::columns(table) + .map(|cols| cols.into_iter().map(|c| (c.name, c.data_type)).collect()) + .ok_or_else(|| { + krishiv_common::error::InternalError { + message: format!("system.{table}: no such system table"), + } + .into() + }) + } + /// `parts` (a name written in a statement's target), with a bare temporary relation placed in the session /// catalog's current namespace: a temporary relation resolves before any catalog, `system` included. fn temp_first(&self, parts: Vec, env: &QueryEnv) -> Vec { diff --git a/crates/krishiv-session/src/introspection.rs b/crates/krishiv-session/src/introspection.rs index 667b80011..f629e6bc0 100644 --- a/crates/krishiv-session/src/introspection.rs +++ b/crates/krishiv-session/src/introspection.rs @@ -544,20 +544,11 @@ impl Session { } }; if let Some(t) = system_table { - let analysis = DataFrame::new( - self.clone(), - Arc::new(krishiv_plan::LogicalPlan::table([ - "system", - &t.to_lowercase(), - ])), - ) - .analyzed_in(env.clone()) - .await?; - let names = analysis - .analyzed - .output + let names = self + .system_table_columns(&t.to_lowercase(), &env) + .await? .iter() - .map(|c| vec![s(&c.name)]) + .map(|(name, _)| vec![s(name)]) .collect(); return self .frame_with_order(&["col_name"], &[], names, false) @@ -958,15 +949,11 @@ impl Session { "DESCRIBE variants other than the plain form of a system table", )); } - let plan = Arc::new(krishiv_plan::LogicalPlan::table(["system", table])); - let analysis = DataFrame::new(self.clone(), plan) - .analyzed_in(env.clone()) - .await?; - let rows: Vec> = analysis - .analyzed - .output + let rows: Vec> = self + .system_table_columns(table, env) + .await? .iter() - .map(|c| vec![s(&c.name), s(c.data_type.catalog_string()), s("")]) + .map(|(name, ty)| vec![s(name), s(ty.catalog_string()), s("")]) .collect(); self.ordered_frame(&["col_name", "data_type", "comment"], &[], rows) .await diff --git a/crates/krishiv-session/src/system.rs b/crates/krishiv-session/src/system.rs index 8556940d3..4279ad5a1 100644 --- a/crates/krishiv-session/src/system.rs +++ b/crates/krishiv-session/src/system.rs @@ -275,8 +275,9 @@ fn layout(table: &str) -> Option> { }) } -/// The columns of `table`, `None` for an unknown table. -fn columns(table: &str) -> Option> { +/// The columns of `table`, `None` for an unknown table (also what `DESCRIBE` and `SHOW COLUMNS` +/// print, which need `Browse`, not the `Select` that building its rows does). +pub(crate) fn columns(table: &str) -> Option> { Some( layout(table)? .into_iter() diff --git a/crates/krishiv-session/tests/roles_matrix.rs b/crates/krishiv-session/tests/roles_matrix.rs index 4fbdc0d14..7f778f73d 100644 --- a/crates/krishiv-session/tests/roles_matrix.rs +++ b/crates/krishiv-session/tests/roles_matrix.rs @@ -72,6 +72,11 @@ const MATRIX: &[(&str, Role)] = &[ ("SELECT count(*) FROM system.queries", Role::Viewer), ("SELECT count(*) FROM SYSTEM.QUERIES", Role::Viewer), ("SELECT count(*) FROM system.capabilities", Role::Viewer), + // A system table's schema is metadata, even where its rows are not (Browse, not Select). + ("DESCRIBE system.memory", Role::Viewer), + ("SHOW COLUMNS IN system.memory", Role::Viewer), + ("DESCRIBE system.operators", Role::Viewer), + ("SHOW COLUMNS IN system.caches", Role::Viewer), // Tables that name other principals' work. ("SELECT count(*) FROM system.memory", Role::Operator), ("SELECT count(*) FROM system.operators", Role::Operator), From e169b222d8db1ef4e441db4675f6a359368b5da2 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:07:21 +0100 Subject: [PATCH 09/47] session: a secret file longer than a token and one line break is refused before the break is removed (Codex review) Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-session/src/auth/secret_ref.rs | 23 +++++++++++++++---- crates/krishiv-session/src/auth/tests.rs | 13 +++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/crates/krishiv-session/src/auth/secret_ref.rs b/crates/krishiv-session/src/auth/secret_ref.rs index 5d606edcd..67faf819a 100644 --- a/crates/krishiv-session/src/auth/secret_ref.rs +++ b/crates/krishiv-session/src/auth/secret_ref.rs @@ -81,12 +81,25 @@ impl SecretRef { check_private(key, &shown, &std::fs::metadata(path).map_err(unreadable)?)?; let file = std::fs::File::open(path).map_err(unreadable)?; check_private(key, &shown, &file.metadata().map_err(unreadable)?)?; - // One byte past the token cap is enough to refuse an oversized secret. - let cap = super::tokens::MAX_SECRET_BYTES as u64 + 2; - let mut text = String::new(); - file.take(cap) - .read_to_string(&mut text) + // A token plus one line break (CRLF) at most: one byte past that refuses the file + // here, before the line break is removed, so a longer file is never cut to fit. + let most = super::tokens::MAX_SECRET_BYTES + 2; + let mut bytes = Vec::new(); + file.take(most as u64 + 1) + .read_to_end(&mut bytes) .map_err(unreadable)?; + if bytes.len() > most { + return Err(invalid( + key, + &shown, + &format!( + "the file is longer than a {}-byte token and one line break", + super::tokens::MAX_SECRET_BYTES + ), + )); + } + let mut text = String::from_utf8(bytes) + .map_err(|_| invalid(key, &shown, "the file is not UTF-8"))?; if text.ends_with('\n') { text.pop(); if text.ends_with('\r') { diff --git a/crates/krishiv-session/src/auth/tests.rs b/crates/krishiv-session/src/auth/tests.rs index fd8756845..96e36846a 100644 --- a/crates/krishiv-session/src/auth/tests.rs +++ b/crates/krishiv-session/src/auth/tests.rs @@ -220,6 +220,19 @@ fn a_fifo_secret_is_refused_without_blocking() { assert!(e.to_string().contains("not a regular file"), "{e}"); } +#[test] +fn an_oversized_secret_file_is_never_cut_to_fit() { + // A full-length token, CRLF, then more: refused, not read as the first 16 KiB. + let dir = tempfile::tempdir().unwrap(); + let text = format!("{}\r\nmore", "z".repeat(16 * 1024)); + let r = secret_file(dir.path(), "s", &text, 0o600); + let e = SecretRef::parse("k", &r, false) + .unwrap() + .resolve("k") + .unwrap_err(); + assert!(e.to_string().contains("longer than"), "{e}"); +} + #[test] fn an_oversized_secret_refuses_startup() { let dir = tempfile::tempdir().unwrap(); From eff5375910001cd2857d576314f4edf190c1ae4e Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:10:47 +0100 Subject: [PATCH 10/47] session, mcp: ReadOnlyPolicy forwards Browse as well as Select, so a read-only viewer keeps its metadata access (Codex review) Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-mcp/src/gate.rs | 4 ++-- crates/krishiv-session/src/access.rs | 5 +++-- crates/krishiv-session/tests/roles_matrix.rs | 21 ++++++++++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/krishiv-mcp/src/gate.rs b/crates/krishiv-mcp/src/gate.rs index 28d0b6809..336367af2 100644 --- a/crates/krishiv-mcp/src/gate.rs +++ b/crates/krishiv-mcp/src/gate.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 //! The read-only gate (ADR 0025, design `P2-T095` section 2.3), three layers: //! the statement must parse as exactly one statement of class `Read` -//! ([`check_read`]); the call's session denies every privilege but `Select` +//! ([`check_read`]); the call's session denies every privilege but `Select` and `Browse` //! through the normal access path ([`SetupLock`]); and the server has no //! write tool. @@ -59,7 +59,7 @@ impl SetupLock { } } - /// From now on only `Select` is allowed. + /// From now on only `Select` and `Browse` are allowed. pub fn lock(&self) { self.locked.store(true, Ordering::Release); } diff --git a/crates/krishiv-session/src/access.rs b/crates/krishiv-session/src/access.rs index 5828277e4..06ed21f67 100644 --- a/crates/krishiv-session/src/access.rs +++ b/crates/krishiv-session/src/access.rs @@ -756,7 +756,7 @@ impl std::fmt::Debug for ReadOnlyPolicy { } impl ReadOnlyPolicy { - /// Read-only over `inner`, which decides every `Select`. + /// Read-only over `inner`, which decides every `Select` and `Browse`. #[must_use] pub fn new(inner: Arc) -> Self { Self { inner } @@ -774,7 +774,8 @@ impl AccessPolicy for ReadOnlyPolicy { securable: &'a Securable, privilege: Privilege, ) -> BoxFuture<'a, Result> { - if privilege != Privilege::Select { + // `Browse` (metadata) reads nothing a `Select` would not; the inner policy decides both. + if !matches!(privilege, Privilege::Select | Privilege::Browse) { return Box::pin(std::future::ready(Ok(AccessDecision::Deny { reason: "the session is read-only".to_owned(), }))); diff --git a/crates/krishiv-session/tests/roles_matrix.rs b/crates/krishiv-session/tests/roles_matrix.rs index 7f778f73d..d867570fd 100644 --- a/crates/krishiv-session/tests/roles_matrix.rs +++ b/crates/krishiv-session/tests/roles_matrix.rs @@ -159,3 +159,24 @@ fn path_reads_need_operator() { assert!(viewer.contains(DENIED), "{viewer}"); run(&session(&rt, Some(Role::Operator)), &sql).unwrap(); } + +#[test] +fn read_only_keeps_a_viewers_metadata_access() { + let dir = tempfile::tempdir().unwrap(); + let rt = runtime(dir.path()); + let s = Session::builder() + .runtime(Arc::clone(&rt)) + .principal(Principal::new("viewer", PrincipalKind::User)) + .access_policy(Arc::new(krishiv_session::ReadOnlyPolicy::new(Arc::new( + RolePolicy::new(Some(Role::Viewer), Role::Admin, PolicyVersion(1)), + )))) + .build() + .unwrap(); + for sql in ["SHOW TABLES", "DESCRIBE t", "SHOW COLUMNS IN v"] { + run(&s, sql).unwrap_or_else(|e| panic!("`{sql}`: {e}")); + } + for sql in ["SELECT * FROM t", "INSERT INTO t VALUES (3)"] { + let e = run(&s, sql).unwrap_err(); + assert!(e.contains(DENIED), "`{sql}`: {e}"); + } +} From dabbc0371b1ea6964db6e84c2a88ebeb7205390f Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:20:34 +0100 Subject: [PATCH 11/47] session: server TLS and client certificates, part 1 (P3-T002 PR-14c): mTLS principal rule over a bounded DER walk, role lists, rustls ServerTls with reload (feature tls), listener coherence checks, cert_names fuzz target Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- .github/workflows/fuzz.yml | 5 + Cargo.lock | 1 + crates/krishiv-session/CLAUDE.md | 2 +- crates/krishiv-session/Cargo.toml | 5 + crates/krishiv-session/public-api.txt | 120 ++++ crates/krishiv-session/src/auth/config.rs | 75 ++- crates/krishiv-session/src/auth/der.rs | 262 ++++++++ crates/krishiv-session/src/auth/mod.rs | 31 +- crates/krishiv-session/src/auth/mtls.rs | 173 ++++++ crates/krishiv-session/src/auth/mtls_tests.rs | 582 ++++++++++++++++++ crates/krishiv-session/src/auth/secret_ref.rs | 66 +- crates/krishiv-session/src/auth/server.rs | 60 +- crates/krishiv-session/src/auth/tls.rs | 190 ++++++ crates/krishiv-session/src/auth/tls_config.rs | 128 ++++ .../tests/fixtures/tls/README.md | 13 + .../krishiv-session/tests/fixtures/tls/ca.pem | 11 + .../tests/fixtures/tls/client.der | Bin 0 -> 472 bytes .../tests/fixtures/tls/client.key | 5 + .../tests/fixtures/tls/client.pem | 12 + .../tests/fixtures/tls/client2.der | Bin 0 -> 480 bytes .../tests/fixtures/tls/client2.key | 5 + .../tests/fixtures/tls/client2.pem | 12 + .../tests/fixtures/tls/expired.key | 5 + .../tests/fixtures/tls/expired.pem | 11 + .../tests/fixtures/tls/nosan.der | Bin 0 -> 398 bytes .../tests/fixtures/tls/nosan.key | 5 + .../tests/fixtures/tls/nosan.pem | 11 + .../tests/fixtures/tls/server.der | Bin 0 -> 425 bytes .../tests/fixtures/tls/server.key | 5 + .../tests/fixtures/tls/server.pem | 11 + .../tests/fixtures/tls/server2.der | Bin 0 -> 427 bytes .../tests/fixtures/tls/server2.key | 5 + .../tests/fixtures/tls/server2.pem | 11 + fuzz/Cargo.toml | 7 + fuzz/fuzz_targets/cert_names.rs | 24 + 35 files changed, 1811 insertions(+), 42 deletions(-) create mode 100644 crates/krishiv-session/src/auth/der.rs create mode 100644 crates/krishiv-session/src/auth/mtls.rs create mode 100644 crates/krishiv-session/src/auth/mtls_tests.rs create mode 100644 crates/krishiv-session/src/auth/tls.rs create mode 100644 crates/krishiv-session/src/auth/tls_config.rs create mode 100644 crates/krishiv-session/tests/fixtures/tls/README.md create mode 100644 crates/krishiv-session/tests/fixtures/tls/ca.pem create mode 100644 crates/krishiv-session/tests/fixtures/tls/client.der create mode 100644 crates/krishiv-session/tests/fixtures/tls/client.key create mode 100644 crates/krishiv-session/tests/fixtures/tls/client.pem create mode 100644 crates/krishiv-session/tests/fixtures/tls/client2.der create mode 100644 crates/krishiv-session/tests/fixtures/tls/client2.key create mode 100644 crates/krishiv-session/tests/fixtures/tls/client2.pem create mode 100644 crates/krishiv-session/tests/fixtures/tls/expired.key create mode 100644 crates/krishiv-session/tests/fixtures/tls/expired.pem create mode 100644 crates/krishiv-session/tests/fixtures/tls/nosan.der create mode 100644 crates/krishiv-session/tests/fixtures/tls/nosan.key create mode 100644 crates/krishiv-session/tests/fixtures/tls/nosan.pem create mode 100644 crates/krishiv-session/tests/fixtures/tls/server.der create mode 100644 crates/krishiv-session/tests/fixtures/tls/server.key create mode 100644 crates/krishiv-session/tests/fixtures/tls/server.pem create mode 100644 crates/krishiv-session/tests/fixtures/tls/server2.der create mode 100644 crates/krishiv-session/tests/fixtures/tls/server2.key create mode 100644 crates/krishiv-session/tests/fixtures/tls/server2.pem create mode 100644 fuzz/fuzz_targets/cert_names.rs diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index ce2921ab6..3c2e71852 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -45,6 +45,8 @@ jobs: args: -max_len=1048576 - target: capsule_import args: -max_len=256 + - target: cert_names + args: -max_len=8192 env: RUSTC_WRAPPER: "" # instrumented builds are not cacheable TARGET: ${{ matrix.target }} @@ -81,6 +83,9 @@ jobs: case "$TARGET" in sql_parse) python3 tests/scripts/fuzz_seed_sql.py "fuzz/corpus/$TARGET" ;; parquet_read) python3 tests/scripts/fuzz_seed_parquet.py "fuzz/corpus/$TARGET" ;; + cert_names) + mkdir -p "fuzz/corpus/$TARGET" + cp crates/krishiv-session/tests/fixtures/tls/*.der "fuzz/corpus/$TARGET/" ;; connect_decode) KRISHIV_FUZZ_SEED_DIR="$PWD/fuzz/corpus/$TARGET" \ cargo test -p krishiv-connect --test goldens --locked ;; diff --git a/Cargo.lock b/Cargo.lock index 037386725..78b9a3688 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2476,6 +2476,7 @@ dependencies = [ "parquet", "prost", "reqwest", + "rustls", "serde_json", "sha2", "tempfile", diff --git a/crates/krishiv-session/CLAUDE.md b/crates/krishiv-session/CLAUDE.md index a496b9d2d..cf7bb6abe 100644 --- a/crates/krishiv-session/CLAUDE.md +++ b/crates/krishiv-session/CLAUDE.md @@ -24,7 +24,7 @@ Paths are relative to this crate. - `src/query_fold.rs` the bookkeeping around one running query: `QueryStart` (counters read at start), `QueryFold`/`MetricStream`/`PendingFold` (the one fold of its metrics into the engine series and `system.queries` when the stream ends, fails or is dropped; holds the admission permit), `QueuedRow` (the history row of a query waiting for admission) - `src/admission.rs` the admission seam (E5): `AdmissionController::admit(&QueryRequest) -> Permit` asked once per query in `run_on_query_logged`; default `Unlimited`; a waiting `admit` shows the query `queued` in `system.queries`, a refusal closes the row `failed`, a drop while queued `cancelled`; the permit (slot, optional per-query memory budget) lives in the query's `QueryFold` and releases on drop; `tests/admission.rs` - `src/access.rs` `StatementInfo` / `Session::statement_info` (class + read securables with columns), write/DDL checks (`authorize_statement`), the `krishiv::audit` denial record, `PathPrefixPolicy` -- `src/auth/` (P3-T002, design `design/P3-T002-authentication.md`, ADR 0046) server authentication: `Role` (viewer < operator < admin), `Credentials` / `Authenticated` / `AuthFailure` (Spark's two `UNAUTHENTICATED` messages), the `Authenticator` trait and `ServerAuth` (throttle, then static tokens; one `krishiv::auth` warn record and one `(surface, method)` counter per failure, never the credential); `config.rs` `AuthConfig::from_settings` (`krishiv.auth.tokens..{secret,principal,kind,role}`, `spark.connect.authenticate.token` = one admin token named `spark`, `krishiv.auth.failureThrottle.*`; unknown `krishiv.auth.*` keys refused); `secret_ref.rs` `env:`/`file:` references (a file readable by group or others refuses startup); `tokens.rs` SHA-256 digests compared in constant time against every entry (secrets >= 32 bytes, no two tokens share one); `throttle.rs` per-address failure blocks, bounded at 65,536 addresses; `roles.rs` `RolePolicy`, the ADR 0046 §3 matrix as an `AccessPolicy` (viewer: `Browse`, own `system.*` rows, `/metrics`; operator: `Select`, `Usage`, `Monitor`, `Operate`; admin: `Modify`/`Create`/`Drop`/`Manage`; client code at `krishiv.auth.udf.minRole`; `Impersonate` never; no role denies all), `tests/roles_matrix.rs`. Metadata statements need `Browse` (`authorize` falls back to `Select` for providers that know only `Select`); `ADMIN`-class statements need `Manage` on `System("cluster")` +- `src/auth/` (P3-T002, design `design/P3-T002-authentication.md`, ADR 0046) server authentication: `Role` (viewer < operator < admin), `Credentials` / `Authenticated` / `AuthFailure` (Spark's two `UNAUTHENTICATED` messages), the `Authenticator` trait and `ServerAuth` (throttle, then static tokens; one `krishiv::auth` warn record and one `(surface, method)` counter per failure, never the credential); `config.rs` `AuthConfig::from_settings` (`krishiv.auth.tokens..{secret,principal,kind,role}`, `spark.connect.authenticate.token` = one admin token named `spark`, `krishiv.auth.failureThrottle.*`; unknown `krishiv.auth.*` keys refused); `secret_ref.rs` `env:`/`file:` references (a file readable by group or others refuses startup); `tokens.rs` SHA-256 digests compared in constant time against every entry (secrets >= 32 bytes, no two tokens share one); `throttle.rs` per-address failure blocks, bounded at 65,536 addresses; `mtls.rs` client certificates (`krishiv.auth.clientCertificates` off/optional/required, `clientCa`, `mtls.principalFrom` san-uri/san-dns/cn: exactly one value of that kind or refused, `mtls.userPrincipals`; roles from `krishiv.auth.roles.{viewer,operator,admin}` lists via `config.rs` `RoleLists`; a bearer token next to a certificate must name the same principal; `required` never falls back to a token); `der.rs` `CertNames::from_der`, the bounded no-recursion DER walk of the leaf's subject CN and DNS/URI SANs (fuzz target `fuzz/fuzz_targets/cert_names.rs` includes it by path); `tls_config.rs` `TlsConfig` (`krishiv.server.tls.{certFile,keyFile,minVersion}`) and `check_listener` (startup refusals: non-loopback without auth, non-loopback tokens without TLS, client certificates without TLS); `tls.rs` (feature `tls`) `ServerTls`, the rustls `ServerConfig` on aws-lc-rs with h2/http1.1 ALPN, WebPKI client verifier, a key file checked like a secret file and `reload()` swapping the certified key (a failed reload keeps the old one); `mtls_tests.rs` with in-memory handshakes against `tests/fixtures/tls/`; `roles.rs` `RolePolicy`, the ADR 0046 §3 matrix as an `AccessPolicy` (viewer: `Browse`, own `system.*` rows, `/metrics`; operator: `Select`, `Usage`, `Monitor`, `Operate`; admin: `Modify`/`Create`/`Drop`/`Manage`; client code at `krishiv.auth.udf.minRole`; `Impersonate` never; no role denies all), `tests/roles_matrix.rs`. Metadata statements need `Browse` (`authorize` falls back to `Select` for providers that know only `Select`); `ADMIN`-class statements need `Manage` on `System("cluster")` - `src/partitions.rs` partitioned and bucketed tables: the layout `CREATE TABLE` stores (partition columns last), the partitions the catalog tracks (`ADD PARTITION`, `SET LOCATION` and static `INSERT` values cast to the column types, `DROP PARTITION` of a managed table: log first, then the directories; each partitioned table has a read/write lock (`InMemoryCatalogStore::partition_ddl` / `partition_write`, keyed by location, weak entries): `RENAME`/`DROP PARTITION`, `MSCK REPAIR` and `TRUNCATE` hold it exclusively (across their log commit retries) and re-read the entry under it, an `INSERT` holds it shared from its write to its partition registration; external tables refused, registered after `INSERT`/CTAS), `DESCRIBE ... PARTITION`, `SHOW PARTITIONS`, `ANALYZE ... PARTITION`; `tests/partitioned_tables.rs` - `src/column_stats.rs` `ANALYZE TABLE .. FOR COLUMNS` (`CommandUtils.statExprs`): one aggregate over the table, kept in `CatalogTable::column_stats` (the catalog is in memory, no on-disk format), printed by `DESCRIBE EXTENDED t col`; a write, `TRUNCATE`, `ADD PARTITION`, `DROP PARTITION` or `SET LOCATION` drops them (`updateTableStats`); published only to the entry the aggregate read (`CatalogTable::incarnation`, set by the store at create, kept by `replace_table`), so a table dropped and created again meanwhile gets none, nor one whose data changed after it was read (`CatalogTable::data_version`, moved by `data_changed`, which every stats-clearing change calls); `MSCK REPAIR` keeps them, as Spark 4.2.0 does; `tests/char_varchar_write.rs` - `src/table_commands/files.rs` a managed table's directory: `authorize_path` (the policy per privilege), `deletable_dir` (inside the root it was created under, no links, `MODIFY`), `delete_table_files`, `remove_dir` diff --git a/crates/krishiv-session/Cargo.toml b/crates/krishiv-session/Cargo.toml index 03dc0c028..463cb2e7b 100644 --- a/crates/krishiv-session/Cargo.toml +++ b/crates/krishiv-session/Cargo.toml @@ -48,6 +48,8 @@ opentelemetry_sdk = { workspace = true, optional = true, features = ["trace"] } tracing-opentelemetry = { workspace = true, optional = true } prost = { workspace = true, optional = true, features = ["std"] } reqwest = { workspace = true, optional = true, features = ["blocking", "rustls"] } +# Server TLS (P3-T002 PR-14c; design §3.5): rustls on the aws-lc-rs provider, never OpenSSL. +rustls = { workspace = true, optional = true, features = ["aws_lc_rs", "std", "tls12"] } tracing-subscriber = { workspace = true, optional = true, features = ["registry", "std"] } # The local file system as a store (`fs`); no other store features. object_store = { workspace = true, features = ["fs"] } @@ -66,6 +68,9 @@ tracing-subscriber = { workspace = true, features = ["registry", "std", "fmt", " # Cloud object stores (S3, GCS, Azure) via `krishiv-io/cloud`. Opt-in so the # default build stays inside the dependency and binary-size budgets. cloud = ["krishiv-exec/cloud", "krishiv-io/cloud"] +# Server TLS for the listeners (`auth::ServerTls`: rustls on aws-lc-rs, reloadable certificate, +# client-certificate verification). `krishiv-server`'s `server` feature turns it on. +tls = ["dep:rustls"] # Access to the logical-plan IR (`Session::from_plan`, `DataFrame::plan`): # for the Python facade and tests; not covered by semver. unstable-ir = [] diff --git a/crates/krishiv-session/public-api.txt b/crates/krishiv-session/public-api.txt index d6e66d414..086beb856 100644 --- a/crates/krishiv-session/public-api.txt +++ b/crates/krishiv-session/public-api.txt @@ -151,6 +151,41 @@ impl core::hash::Hash for krishiv_session::auth::AuthFailure pub fn krishiv_session::auth::AuthFailure::hash<__H: core::hash::Hasher>(&self, &mut __H) impl core::marker::Copy for krishiv_session::auth::AuthFailure impl core::marker::StructuralPartialEq for krishiv_session::auth::AuthFailure +#[non_exhaustive] pub enum krishiv_session::auth::ClientCertificates +pub krishiv_session::auth::ClientCertificates::Off +pub krishiv_session::auth::ClientCertificates::Optional +pub krishiv_session::auth::ClientCertificates::Required +impl krishiv_session::auth::ClientCertificates +pub fn krishiv_session::auth::ClientCertificates::as_str(self) -> &'static str +impl core::clone::Clone for krishiv_session::auth::ClientCertificates +pub fn krishiv_session::auth::ClientCertificates::clone(&self) -> krishiv_session::auth::ClientCertificates +impl core::cmp::Eq for krishiv_session::auth::ClientCertificates +impl core::cmp::PartialEq for krishiv_session::auth::ClientCertificates +pub fn krishiv_session::auth::ClientCertificates::eq(&self, &krishiv_session::auth::ClientCertificates) -> bool +impl core::default::Default for krishiv_session::auth::ClientCertificates +pub fn krishiv_session::auth::ClientCertificates::default() -> krishiv_session::auth::ClientCertificates +impl core::fmt::Debug for krishiv_session::auth::ClientCertificates +pub fn krishiv_session::auth::ClientCertificates::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Copy for krishiv_session::auth::ClientCertificates +impl core::marker::StructuralPartialEq for krishiv_session::auth::ClientCertificates +#[non_exhaustive] pub enum krishiv_session::auth::DerError +pub krishiv_session::auth::DerError::BadLength +pub krishiv_session::auth::DerError::BadText +pub krishiv_session::auth::DerError::DuplicateExtension +pub krishiv_session::auth::DerError::Trailing +pub krishiv_session::auth::DerError::Truncated +pub krishiv_session::auth::DerError::Unexpected +impl core::clone::Clone for krishiv_session::auth::DerError +pub fn krishiv_session::auth::DerError::clone(&self) -> krishiv_session::auth::DerError +impl core::cmp::Eq for krishiv_session::auth::DerError +impl core::cmp::PartialEq for krishiv_session::auth::DerError +pub fn krishiv_session::auth::DerError::eq(&self, &krishiv_session::auth::DerError) -> bool +impl core::fmt::Debug for krishiv_session::auth::DerError +pub fn krishiv_session::auth::DerError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for krishiv_session::auth::DerError +pub fn krishiv_session::auth::DerError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Copy for krishiv_session::auth::DerError +impl core::marker::StructuralPartialEq for krishiv_session::auth::DerError #[non_exhaustive] pub enum krishiv_session::auth::Method pub krishiv_session::auth::Method::Mtls pub krishiv_session::auth::Method::None @@ -171,6 +206,23 @@ impl core::hash::Hash for krishiv_session::auth::Method pub fn krishiv_session::auth::Method::hash<__H: core::hash::Hasher>(&self, &mut __H) impl core::marker::Copy for krishiv_session::auth::Method impl core::marker::StructuralPartialEq for krishiv_session::auth::Method +#[non_exhaustive] pub enum krishiv_session::auth::PrincipalFrom +pub krishiv_session::auth::PrincipalFrom::Cn +pub krishiv_session::auth::PrincipalFrom::SanDns +pub krishiv_session::auth::PrincipalFrom::SanUri +impl krishiv_session::auth::PrincipalFrom +pub fn krishiv_session::auth::PrincipalFrom::as_str(self) -> &'static str +impl core::clone::Clone for krishiv_session::auth::PrincipalFrom +pub fn krishiv_session::auth::PrincipalFrom::clone(&self) -> krishiv_session::auth::PrincipalFrom +impl core::cmp::Eq for krishiv_session::auth::PrincipalFrom +impl core::cmp::PartialEq for krishiv_session::auth::PrincipalFrom +pub fn krishiv_session::auth::PrincipalFrom::eq(&self, &krishiv_session::auth::PrincipalFrom) -> bool +impl core::default::Default for krishiv_session::auth::PrincipalFrom +pub fn krishiv_session::auth::PrincipalFrom::default() -> krishiv_session::auth::PrincipalFrom +impl core::fmt::Debug for krishiv_session::auth::PrincipalFrom +pub fn krishiv_session::auth::PrincipalFrom::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Copy for krishiv_session::auth::PrincipalFrom +impl core::marker::StructuralPartialEq for krishiv_session::auth::PrincipalFrom pub enum krishiv_session::auth::Role pub krishiv_session::auth::Role::Admin pub krishiv_session::auth::Role::Operator @@ -231,6 +283,8 @@ pub fn krishiv_session::auth::Surface::hash<__H: core::hash::Hasher>(&self, &mut impl core::marker::Copy for krishiv_session::auth::Surface impl core::marker::StructuralPartialEq for krishiv_session::auth::Surface #[non_exhaustive] pub struct krishiv_session::auth::AuthConfig +pub krishiv_session::auth::AuthConfig::mtls: krishiv_session::auth::MtlsConfig +pub krishiv_session::auth::AuthConfig::roles: krishiv_session::auth::RoleLists pub krishiv_session::auth::AuthConfig::throttle: krishiv_session::auth::ThrottleConfig pub krishiv_session::auth::AuthConfig::tokens: alloc::vec::Vec impl krishiv_session::auth::AuthConfig @@ -260,10 +314,27 @@ pub fn krishiv_session::auth::Authenticated::eq(&self, &krishiv_session::auth::A impl core::fmt::Debug for krishiv_session::auth::Authenticated pub fn krishiv_session::auth::Authenticated::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for krishiv_session::auth::Authenticated +#[non_exhaustive] pub struct krishiv_session::auth::CertNames +pub krishiv_session::auth::CertNames::common_names: alloc::vec::Vec +pub krishiv_session::auth::CertNames::dns: alloc::vec::Vec +pub krishiv_session::auth::CertNames::uris: alloc::vec::Vec +impl krishiv_session::auth::CertNames +pub fn krishiv_session::auth::CertNames::from_der(&[u8]) -> core::result::Result +impl core::clone::Clone for krishiv_session::auth::CertNames +pub fn krishiv_session::auth::CertNames::clone(&self) -> krishiv_session::auth::CertNames +impl core::cmp::Eq for krishiv_session::auth::CertNames +impl core::cmp::PartialEq for krishiv_session::auth::CertNames +pub fn krishiv_session::auth::CertNames::eq(&self, &krishiv_session::auth::CertNames) -> bool +impl core::default::Default for krishiv_session::auth::CertNames +pub fn krishiv_session::auth::CertNames::default() -> krishiv_session::auth::CertNames +impl core::fmt::Debug for krishiv_session::auth::CertNames +pub fn krishiv_session::auth::CertNames::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for krishiv_session::auth::CertNames #[non_exhaustive] pub struct krishiv_session::auth::Credentials<'a> pub krishiv_session::auth::Credentials::bearer: core::option::Option<&'a str> pub krishiv_session::auth::Credentials::malformed: bool pub krishiv_session::auth::Credentials::peer: core::net::socket_addr::SocketAddr +pub krishiv_session::auth::Credentials::peer_leaf: core::option::Option<&'a [u8]> pub krishiv_session::auth::Credentials::surface: krishiv_session::auth::Surface impl<'a> krishiv_session::auth::Credentials<'a> pub fn krishiv_session::auth::Credentials<'a>::new(core::net::socket_addr::SocketAddr, krishiv_session::auth::Surface) -> Self @@ -294,6 +365,37 @@ pub fn krishiv_session::auth::FailureThrottle::new(krishiv_session::auth::Thrott pub fn krishiv_session::auth::FailureThrottle::record_failure(&self, core::net::ip_addr::IpAddr, std::time::Instant) -> bool impl core::fmt::Debug for krishiv_session::auth::FailureThrottle pub fn krishiv_session::auth::FailureThrottle::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct krishiv_session::auth::MtlsConfig +pub krishiv_session::auth::MtlsConfig::client_ca: core::option::Option +pub krishiv_session::auth::MtlsConfig::client_certificates: krishiv_session::auth::ClientCertificates +pub krishiv_session::auth::MtlsConfig::principal_from: krishiv_session::auth::PrincipalFrom +pub krishiv_session::auth::MtlsConfig::user_principals: alloc::collections::btree::set::BTreeSet +impl krishiv_session::auth::MtlsConfig +pub fn krishiv_session::auth::MtlsConfig::is_on(&self) -> bool +pub fn krishiv_session::auth::MtlsConfig::principal_of(&self, &[u8]) -> core::option::Option<(alloc::string::String, krishiv_common::principal::PrincipalKind)> +impl core::clone::Clone for krishiv_session::auth::MtlsConfig +pub fn krishiv_session::auth::MtlsConfig::clone(&self) -> krishiv_session::auth::MtlsConfig +impl core::cmp::Eq for krishiv_session::auth::MtlsConfig +impl core::cmp::PartialEq for krishiv_session::auth::MtlsConfig +pub fn krishiv_session::auth::MtlsConfig::eq(&self, &krishiv_session::auth::MtlsConfig) -> bool +impl core::default::Default for krishiv_session::auth::MtlsConfig +pub fn krishiv_session::auth::MtlsConfig::default() -> krishiv_session::auth::MtlsConfig +impl core::fmt::Debug for krishiv_session::auth::MtlsConfig +pub fn krishiv_session::auth::MtlsConfig::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for krishiv_session::auth::MtlsConfig +pub struct krishiv_session::auth::RoleLists +impl krishiv_session::auth::RoleLists +pub fn krishiv_session::auth::RoleLists::role_of(&self, &str) -> core::option::Option +impl core::clone::Clone for krishiv_session::auth::RoleLists +pub fn krishiv_session::auth::RoleLists::clone(&self) -> krishiv_session::auth::RoleLists +impl core::cmp::Eq for krishiv_session::auth::RoleLists +impl core::cmp::PartialEq for krishiv_session::auth::RoleLists +pub fn krishiv_session::auth::RoleLists::eq(&self, &krishiv_session::auth::RoleLists) -> bool +impl core::default::Default for krishiv_session::auth::RoleLists +pub fn krishiv_session::auth::RoleLists::default() -> krishiv_session::auth::RoleLists +impl core::fmt::Debug for krishiv_session::auth::RoleLists +pub fn krishiv_session::auth::RoleLists::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for krishiv_session::auth::RoleLists pub struct krishiv_session::auth::RolePolicy impl krishiv_session::auth::RolePolicy pub fn krishiv_session::auth::RolePolicy::allows(&self, &krishiv_plan::analyzer::catalog::Securable, krishiv_plan::analyzer::catalog::Privilege) -> bool @@ -349,6 +451,23 @@ impl core::fmt::Debug for krishiv_session::auth::ThrottleConfig pub fn krishiv_session::auth::ThrottleConfig::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::Copy for krishiv_session::auth::ThrottleConfig impl core::marker::StructuralPartialEq for krishiv_session::auth::ThrottleConfig +pub struct krishiv_session::auth::TlsConfig +pub krishiv_session::auth::TlsConfig::cert_file: core::option::Option +pub krishiv_session::auth::TlsConfig::key_file: core::option::Option +pub krishiv_session::auth::TlsConfig::tls13_only: bool +impl krishiv_session::auth::TlsConfig +pub fn krishiv_session::auth::TlsConfig::from_settings<'a>(impl core::iter::traits::collect::IntoIterator) -> core::result::Result +pub fn krishiv_session::auth::TlsConfig::is_on(&self) -> bool +impl core::clone::Clone for krishiv_session::auth::TlsConfig +pub fn krishiv_session::auth::TlsConfig::clone(&self) -> krishiv_session::auth::TlsConfig +impl core::cmp::Eq for krishiv_session::auth::TlsConfig +impl core::cmp::PartialEq for krishiv_session::auth::TlsConfig +pub fn krishiv_session::auth::TlsConfig::eq(&self, &krishiv_session::auth::TlsConfig) -> bool +impl core::default::Default for krishiv_session::auth::TlsConfig +pub fn krishiv_session::auth::TlsConfig::default() -> krishiv_session::auth::TlsConfig +impl core::fmt::Debug for krishiv_session::auth::TlsConfig +pub fn krishiv_session::auth::TlsConfig::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for krishiv_session::auth::TlsConfig #[non_exhaustive] pub struct krishiv_session::auth::TokenSpec pub krishiv_session::auth::TokenSpec::kind: krishiv_common::principal::PrincipalKind pub krishiv_session::auth::TokenSpec::name: alloc::string::String @@ -368,6 +487,7 @@ pub fn krishiv_session::auth::Authenticator::authenticate<'a>(&'a self, &'a kris impl krishiv_session::auth::Authenticator for krishiv_session::auth::ServerAuth pub fn krishiv_session::auth::ServerAuth::authenticate<'a>(&'a self, &'a krishiv_session::auth::Credentials<'a>) -> futures_core::future::BoxFuture<'a, core::result::Result> pub fn krishiv_session::auth::bearer_token(&str) -> core::option::Option<&str> +pub fn krishiv_session::auth::check_listener(core::net::socket_addr::SocketAddr, &krishiv_session::auth::AuthConfig, &krishiv_session::auth::TlsConfig) -> core::result::Result<(), krishiv_common::error::KrishivError> pub fn krishiv_session::auth::is_auth_key(&str) -> bool pub mod krishiv_session::functions pub trait krishiv_session::functions::IntoLiteral diff --git a/crates/krishiv-session/src/auth/config.rs b/crates/krishiv-session/src/auth/config.rs index 7779f9c6d..804154be4 100644 --- a/crates/krishiv-session/src/auth/config.rs +++ b/crates/krishiv-session/src/auth/config.rs @@ -13,8 +13,17 @@ //! krishiv.auth.failureThrottle.maxFailures = 20 (per peer address and window) //! krishiv.auth.failureThrottle.window = 60s //! krishiv.auth.failureThrottle.blockFor = 60s +//! krishiv.auth.clientCertificates = off | optional | required (default off) +//! krishiv.auth.clientCa = file:/path (PEM; with client certificates) +//! krishiv.auth.mtls.principalFrom = san-uri | san-dns | cn (default san-uri) +//! krishiv.auth.mtls.userPrincipals = alice, bob (certificate principals that are users) +//! krishiv.auth.roles.{viewer,operator,admin} = a, b (principal names; ADR 0046 §6) //! ``` //! +//! A static token names its own role. A certificate principal (and, later, an OIDC claim) +//! gets its role from the `krishiv.auth.roles.*` lists; a name in none of them has no role, +//! which denies everything. +//! //! Every key is startup-only. An unknown `krishiv.auth.*` key is refused, so a misspelt //! setting never leaves a server open. @@ -25,6 +34,7 @@ use krishiv_common::error::KrishivError; use krishiv_common::principal::PrincipalKind; use super::Role; +use super::mtls::MtlsConfig; use super::secret_ref::{SecretRef, invalid}; /// The key prefix of every Krishiv authentication setting. @@ -86,6 +96,44 @@ pub struct AuthConfig { pub tokens: Vec, /// Failure throttling. pub throttle: ThrottleConfig, + /// Client certificates. + pub mtls: MtlsConfig, + /// The roles of principals that do not carry one (certificates). + pub roles: RoleLists, +} + +/// `krishiv.auth.roles.{viewer,operator,admin}`: principal names by role. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RoleLists { + by_name: BTreeMap, +} + +impl RoleLists { + /// The role of `name`, if a list names it. + #[must_use] + pub fn role_of(&self, name: &str) -> Option { + self.by_name.get(name).copied() + } + + /// Sets the list of `role` from the setting `key = raw` (comma separated names). + fn set(&mut self, key: &str, role: Role, raw: &str) -> Result<(), KrishivError> { + self.by_name.retain(|_, r| *r != role); + for name in raw.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if let Some(other) = self.by_name.get(name) + && *other != role + { + return Err(invalid( + key, + name, + &format!( + "`{name}` is already listed as {other}; a principal has exactly one role" + ), + )); + } + self.by_name.insert(name.to_owned(), role); + } + Ok(()) + } } /// The fields of one `krishiv.auth.tokens.` group as given. @@ -111,6 +159,8 @@ impl AuthConfig { let mut spark: Option = None; let mut spark_principal = SPARK_TOKEN_NAME.to_owned(); let mut throttle = ThrottleConfig::default(); + let mut mtls = MtlsConfig::default(); + let mut role_lists: BTreeMap = BTreeMap::new(); for (key, raw) in settings { if key == SPARK_TOKEN_KEY { spark = Some(SecretRef::parse(key, raw, true)?); @@ -136,6 +186,16 @@ impl AuthConfig { } continue; } + if mtls.apply(key, rest, raw)? { + continue; + } + if let Some(role) = rest.strip_prefix("roles.") { + let role = Role::parse(role) + .filter(|r| r.as_str() == role) + .ok_or_else(|| unknown(key))?; + role_lists.insert(role, (key.to_owned(), raw.to_owned())); + continue; + } match rest { "sparkTokenPrincipal" => spark_principal = non_empty(key, raw)?, "failureThrottle.maxFailures" => { @@ -183,13 +243,24 @@ impl AuthConfig { role: Role::Admin, }); } - Ok(Self { tokens, throttle }) + mtls.validate()?; + // Each list once, whole, so a key given twice keeps its last value. + let mut roles = RoleLists::default(); + for (role, (key, raw)) in role_lists { + roles.set(&key, role, &raw)?; + } + Ok(Self { + tokens, + throttle, + mtls, + roles, + }) } /// Whether any authentication method is configured. #[must_use] pub fn is_configured(&self) -> bool { - !self.tokens.is_empty() + !self.tokens.is_empty() || self.mtls.is_on() } } diff --git a/crates/krishiv-session/src/auth/der.rs b/crates/krishiv-session/src/auth/der.rs new file mode 100644 index 000000000..5bb303adc --- /dev/null +++ b/crates/krishiv-session/src/auth/der.rs @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: Apache-2.0 +//! The names in an X.509 certificate a client presents (design §3.4; ADR 0046 §6): the subject +//! common names and the DNS and URI subject alternative names, read by a minimal DER walk. +//! +//! The chain was verified by rustls at the handshake; this only reads names out of the leaf. +//! The walk is strict and bounded: definite lengths only, no high tag numbers, no trailing +//! bytes where a structure ends, every length inside its parent. Anything else is +//! [`DerError`], which refuses the certificate. There is no recursion. `fuzz_targets/cert_names.rs` +//! fuzzes [`CertNames::from_der`]. + +/// What went wrong reading a certificate. Never carries certificate bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum DerError { + /// The input ended inside an element. + Truncated, + /// An element is not the one the certificate structure has at that place. + Unexpected, + /// A length form DER does not allow (indefinite, over four bytes, or not minimal). + BadLength, + /// Bytes left after the last element of a structure. + Trailing, + /// A name is not text of the type its tag says (or holds a control character). + BadText, + /// The certificate has two subject alternative name extensions. + DuplicateExtension, +} + +impl std::fmt::Display for DerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Truncated => "truncated element", + Self::Unexpected => "unexpected element", + Self::BadLength => "length not in DER form", + Self::Trailing => "bytes after the last element", + Self::BadText => "a name is not valid text", + Self::DuplicateExtension => "two subjectAltName extensions", + }) + } +} + +/// The names of a certificate's subject. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct CertNames { + /// Every `commonName` attribute of the subject, in order. + pub common_names: Vec, + /// Every `dNSName` of the `subjectAltName` extension, in order. + pub dns: Vec, + /// Every `uniformResourceIdentifier` of the `subjectAltName` extension, in order. + pub uris: Vec, +} + +// Universal tags. +const BOOLEAN: u8 = 0x01; +const INTEGER: u8 = 0x02; +const OCTET_STRING: u8 = 0x04; +const OID: u8 = 0x06; +const UTF8_STRING: u8 = 0x0c; +const PRINTABLE_STRING: u8 = 0x13; +const IA5_STRING: u8 = 0x16; +const SEQUENCE: u8 = 0x30; +const SET: u8 = 0x31; +// Context tags of `TBSCertificate` and `GeneralName`. +const VERSION: u8 = 0xa0; +const ISSUER_UID: u8 = 0x81; +const SUBJECT_UID: u8 = 0x82; +const EXTENSIONS: u8 = 0xa3; +const DNS_NAME: u8 = 0x82; +const URI: u8 = 0x86; + +/// `id-at-commonName`, 2.5.4.3. +const OID_COMMON_NAME: &[u8] = &[0x55, 0x04, 0x03]; +/// `id-ce-subjectAltName`, 2.5.29.17. +const OID_SUBJECT_ALT_NAME: &[u8] = &[0x55, 0x1d, 0x11]; + +/// A cursor over the elements of one DER structure. +struct Reader<'a> { + rest: &'a [u8], +} + +impl<'a> Reader<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { rest: bytes } + } + + fn is_empty(&self) -> bool { + self.rest.is_empty() + } + + /// The tag of the next element, if any. + fn peek(&self) -> Option { + self.rest.first().copied() + } + + /// The next element: its tag and contents. + fn any(&mut self) -> Result<(u8, &'a [u8]), DerError> { + let (&tag, after) = self.rest.split_first().ok_or(DerError::Truncated)?; + // High tag numbers (low five bits all set) never occur in the fields read here. + if tag & 0x1f == 0x1f { + return Err(DerError::Unexpected); + } + let (&first, mut after) = after.split_first().ok_or(DerError::Truncated)?; + let len = if first < 0x80 { + usize::from(first) + } else { + let count = usize::from(first & 0x7f); + // 0x80 is BER's indefinite form; more than four length bytes is no real certificate. + if count == 0 || count > 4 { + return Err(DerError::BadLength); + } + let (bytes, tail) = after.split_at_checked(count).ok_or(DerError::Truncated)?; + after = tail; + if bytes.first() == Some(&0) { + return Err(DerError::BadLength); + } + let len = bytes + .iter() + .fold(0usize, |acc, &b| (acc << 8) | usize::from(b)); + if len < 0x80 { + return Err(DerError::BadLength); + } + len + }; + let (contents, tail) = after.split_at_checked(len).ok_or(DerError::Truncated)?; + self.rest = tail; + Ok((tag, contents)) + } + + /// The next element, which must have tag `tag`. + fn expect(&mut self, tag: u8) -> Result<&'a [u8], DerError> { + match self.any()? { + (t, contents) if t == tag => Ok(contents), + _ => Err(DerError::Unexpected), + } + } + + /// The next element if it has tag `tag`. + fn optional(&mut self, tag: u8) -> Result, DerError> { + if self.peek() == Some(tag) { + self.expect(tag).map(Some) + } else { + Ok(None) + } + } + + /// Nothing may follow. + fn finish(&self) -> Result<(), DerError> { + if self.is_empty() { + Ok(()) + } else { + Err(DerError::Trailing) + } + } +} + +impl CertNames { + /// The names of the DER certificate `cert`. + /// + /// # Errors + /// [`DerError`] when `cert` is not a DER certificate, or a name read is not valid text. + pub fn from_der(cert: &[u8]) -> Result { + let mut outer = Reader::new(cert); + let certificate = outer.expect(SEQUENCE)?; + outer.finish()?; + let mut certificate = Reader::new(certificate); + let tbs = certificate.expect(SEQUENCE)?; + // signatureAlgorithm and signatureValue follow; rustls checked them. + let mut tbs = Reader::new(tbs); + tbs.optional(VERSION)?; + tbs.expect(INTEGER)?; // serialNumber + tbs.expect(SEQUENCE)?; // signature + tbs.expect(SEQUENCE)?; // issuer + tbs.expect(SEQUENCE)?; // validity + let subject = tbs.expect(SEQUENCE)?; + tbs.expect(SEQUENCE)?; // subjectPublicKeyInfo + tbs.optional(ISSUER_UID)?; + tbs.optional(SUBJECT_UID)?; + let extensions = tbs.optional(EXTENSIONS)?; + tbs.finish()?; + + let mut names = Self { + common_names: common_names(subject)?, + ..Self::default() + }; + if let Some(extensions) = extensions { + let mut explicit = Reader::new(extensions); + let list = explicit.expect(SEQUENCE)?; + explicit.finish()?; + let mut list = Reader::new(list); + let mut seen = false; + while !list.is_empty() { + let mut ext = Reader::new(list.expect(SEQUENCE)?); + let id = ext.expect(OID)?; + ext.optional(BOOLEAN)?; // critical + let value = ext.expect(OCTET_STRING)?; + ext.finish()?; + if id == OID_SUBJECT_ALT_NAME { + if seen { + return Err(DerError::DuplicateExtension); + } + seen = true; + alt_names(value, &mut names)?; + } + } + } + Ok(names) + } +} + +/// The `commonName` values of the `Name` whose contents are `subject` +/// (`RDNSequence`: a sequence of sets of attribute type and value). +fn common_names(subject: &[u8]) -> Result, DerError> { + let mut out = Vec::new(); + let mut rdns = Reader::new(subject); + while !rdns.is_empty() { + let mut set = Reader::new(rdns.expect(SET)?); + while !set.is_empty() { + let mut attr = Reader::new(set.expect(SEQUENCE)?); + let id = attr.expect(OID)?; + let (tag, value) = attr.any()?; + attr.finish()?; + if id == OID_COMMON_NAME { + out.push(text(tag, value)?); + } + } + } + Ok(out) +} + +/// The DNS and URI names of the `GeneralNames` whose DER is `value`; other kinds are skipped. +fn alt_names(value: &[u8], names: &mut CertNames) -> Result<(), DerError> { + let mut outer = Reader::new(value); + let list = outer.expect(SEQUENCE)?; + outer.finish()?; + let mut list = Reader::new(list); + while !list.is_empty() { + match list.any()? { + (DNS_NAME, v) => names.dns.push(text(IA5_STRING, v)?), + (URI, v) => names.uris.push(text(IA5_STRING, v)?), + _ => {} + } + } + Ok(()) +} + +/// The string of a `DirectoryString` or `IA5String` element: UTF-8 for `UTF8String`, ASCII +/// for the others, never a control character. `TeletexString`, `BMPString` and +/// `UniversalString` are refused. +fn text(tag: u8, bytes: &[u8]) -> Result { + let s = match tag { + UTF8_STRING => std::str::from_utf8(bytes).map_err(|_| DerError::BadText)?, + PRINTABLE_STRING | IA5_STRING if bytes.is_ascii() => { + std::str::from_utf8(bytes).map_err(|_| DerError::BadText)? + } + _ => return Err(DerError::BadText), + }; + if s.chars().any(char::is_control) { + return Err(DerError::BadText); + } + Ok(s.to_owned()) +} diff --git a/crates/krishiv-session/src/auth/mod.rs b/crates/krishiv-session/src/auth/mod.rs index ea5c4b5ba..313d9bf81 100644 --- a/crates/krishiv-session/src/auth/mod.rs +++ b/crates/krishiv-session/src/auth/mod.rs @@ -1,8 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 //! Server authentication (P3-T002; design `design/P3-T002-authentication.md`, ADR 0046). //! -//! One [`Authenticator`] turns what a transport received (a bearer token today; client -//! certificates in a later unit) into an [`Authenticated`] principal with a [`Role`]. The Spark +//! One [`Authenticator`] turns what a transport received (a bearer token, a verified client +//! certificate) into an [`Authenticated`] principal with a [`Role`]. The Spark //! Connect listener, the admin HTTP server, `/metrics` and the UI all call the same //! [`ServerAuth`]; none keeps a token of its own. //! @@ -14,16 +14,28 @@ //! - [`StaticTokens`]: one principal per configured token, held as SHA-256 digests and compared //! in constant time against every entry. //! - [`FailureThrottle`]: repeated failures from one peer address are refused for a while. -//! - [`ServerAuth`]: the chain, its failure log record (target `krishiv::auth`, never the +//! - [`MtlsConfig`]: client certificates; the one `principalFrom` rule reads the principal out +//! of the leaf with a minimal DER walk ([`CertNames`]). +//! - [`TlsConfig`] and [`check_listener`]: the `krishiv.server.tls.*` settings and the startup +//! refusals of an address, authentication and TLS that do not fit together; `ServerTls` +//! (feature `tls`) is the rustls configuration the listeners serve, reloadable. +//! - [`ServerAuth`]: the chain (throttle, client certificate, bearer token), its failure log record (target `krishiv::auth`, never the //! credential) and its failure counters. mod config; +mod der; +mod mtls; mod roles; mod secret_ref; mod server; mod throttle; +#[cfg(feature = "tls")] +mod tls; +mod tls_config; mod tokens; +#[cfg(test)] +mod mtls_tests; #[cfg(test)] mod tests; @@ -33,11 +45,16 @@ use std::net::SocketAddr; use futures::future::BoxFuture; use krishiv_common::principal::Principal; -pub use config::{AuthConfig, ThrottleConfig, TokenSpec, is_auth_key}; +pub use config::{AuthConfig, RoleLists, ThrottleConfig, TokenSpec, is_auth_key}; +pub use der::{CertNames, DerError}; +pub use mtls::{ClientCertificates, MtlsConfig, PrincipalFrom}; pub use roles::RolePolicy; pub use secret_ref::SecretRef; pub use server::{FailureCount, ServerAuth}; pub use throttle::FailureThrottle; +#[cfg(feature = "tls")] +pub use tls::ServerTls; +pub use tls_config::{TlsConfig, check_listener}; pub use tokens::StaticTokens; /// A server role (ADR 0046 §1): a total order, so a check is `role >= required`. @@ -115,7 +132,7 @@ pub enum Method { Token, /// An OIDC access token (a later unit). Oidc, - /// A client certificate (a later unit). + /// A client certificate the client CA verified. Mtls, /// Refused before any method ran (the peer is throttled). Throttle, @@ -152,6 +169,9 @@ impl Method { pub struct Credentials<'a> { /// The token of an `authorization: Bearer ` header or gRPC metadata entry. pub bearer: Option<&'a str>, + /// The client's leaf certificate (DER), which the TLS layer verified against the client + /// CA at the handshake. Only the verified leaf may be passed here. + pub peer_leaf: Option<&'a [u8]>, /// The peer's address (the throttle's key). pub peer: SocketAddr, /// The listener. @@ -167,6 +187,7 @@ impl<'a> Credentials<'a> { pub fn new(peer: SocketAddr, surface: Surface) -> Self { Self { bearer: None, + peer_leaf: None, peer, surface, malformed: false, diff --git a/crates/krishiv-session/src/auth/mtls.rs b/crates/krishiv-session/src/auth/mtls.rs new file mode 100644 index 000000000..0144a4656 --- /dev/null +++ b/crates/krishiv-session/src/auth/mtls.rs @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Client certificates (design §3.4; ADR 0046 §6): whether the listeners ask for one, and the +//! one rule that names the principal of a verified leaf certificate. +//! +//! rustls verifies the chain, the client CA and the validity dates at the handshake +//! ([`super::tls`]); this reads the principal's name out of the leaf. No revocation (CRL, OCSP) +//! in P3. + +use std::collections::BTreeSet; +use std::path::PathBuf; + +use krishiv_common::error::KrishivError; +use krishiv_common::principal::PrincipalKind; + +use super::der::CertNames; +use super::secret_ref::invalid; + +/// `krishiv.auth.clientCertificates`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[non_exhaustive] +pub enum ClientCertificates { + /// No client certificate is asked for (the default). + #[default] + Off, + /// A client may present one; one without a certificate authenticates by bearer token. + Optional, + /// The handshake fails without a certificate the client CA verifies. + Required, +} + +impl ClientCertificates { + /// The setting's value. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Off => "off", + Self::Optional => "optional", + Self::Required => "required", + } + } + + fn parse(key: &str, raw: &str) -> Result { + match raw.trim().to_ascii_lowercase().as_str() { + "off" => Ok(Self::Off), + "optional" => Ok(Self::Optional), + "required" => Ok(Self::Required), + _ => Err(invalid(key, raw, "expected off, optional or required")), + } + } +} + +/// `krishiv.auth.mtls.principalFrom`: which field of the leaf names the principal. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[non_exhaustive] +pub enum PrincipalFrom { + /// The single URI subject alternative name, e.g. a SPIFFE id (the default). + #[default] + SanUri, + /// The single DNS subject alternative name. + SanDns, + /// The single subject common name. + Cn, +} + +impl PrincipalFrom { + fn parse(key: &str, raw: &str) -> Result { + match raw.trim().to_ascii_lowercase().as_str() { + "san-uri" => Ok(Self::SanUri), + "san-dns" => Ok(Self::SanDns), + "cn" => Ok(Self::Cn), + _ => Err(invalid(key, raw, "expected san-uri, san-dns or cn")), + } + } + + /// The setting's value. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::SanUri => "san-uri", + Self::SanDns => "san-dns", + Self::Cn => "cn", + } + } +} + +/// The `krishiv.auth.clientCertificates`, `krishiv.auth.clientCa` and `krishiv.auth.mtls.*` +/// settings. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct MtlsConfig { + /// Whether a client certificate is asked for. + pub client_certificates: ClientCertificates, + /// The PEM file of the CA certificates that sign client certificates (`file:/path`). + pub client_ca: Option, + /// The field that names the principal. + pub principal_from: PrincipalFrom, + /// Certificate principals that are users; every other one is a service. + pub user_principals: BTreeSet, +} + +impl MtlsConfig { + /// Applies the setting `rest` (the key after `krishiv.auth.`), if it is one of these. + /// Returns whether it was. + pub(crate) fn apply(&mut self, key: &str, rest: &str, raw: &str) -> Result { + match rest { + "clientCertificates" => self.client_certificates = ClientCertificates::parse(key, raw)?, + "clientCa" => { + let path = raw + .strip_prefix("file:") + .filter(|p| !p.is_empty()) + .ok_or_else(|| invalid(key, raw, "expected file:/path of a PEM file"))?; + self.client_ca = Some(PathBuf::from(path)); + } + "mtls.principalFrom" => self.principal_from = PrincipalFrom::parse(key, raw)?, + "mtls.userPrincipals" => { + self.user_principals = raw + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(); + } + _ => return Ok(false), + } + Ok(true) + } + + /// Whether client certificates are asked for. + #[must_use] + pub fn is_on(&self) -> bool { + self.client_certificates != ClientCertificates::Off + } + + /// Startup checks that need no other setting: a client CA exactly when certificates are on. + pub(crate) fn validate(&self) -> Result<(), KrishivError> { + match (self.is_on(), &self.client_ca) { + (true, None) => Err(invalid( + "krishiv.auth.clientCa", + "", + "required when krishiv.auth.clientCertificates is optional or required", + )), + (false, Some(_)) => Err(invalid( + "krishiv.auth.clientCa", + "", + "set, but krishiv.auth.clientCertificates is off", + )), + _ => Ok(()), + } + } + + /// The principal named by the verified leaf certificate `leaf` (DER), and its kind. + /// + /// `None` when the certificate cannot be read or has zero or several values of the + /// configured kind (no fallback to another kind). + #[must_use] + pub fn principal_of(&self, leaf: &[u8]) -> Option<(String, PrincipalKind)> { + let names = CertNames::from_der(leaf).ok()?; + let values = match self.principal_from { + PrincipalFrom::SanUri => names.uris, + PrincipalFrom::SanDns => names.dns, + PrincipalFrom::Cn => names.common_names, + }; + let [name] = <[String; 1]>::try_from(values).ok()?; + if name.is_empty() { + return None; + } + let kind = if self.user_principals.contains(&name) { + PrincipalKind::User + } else { + PrincipalKind::Service + }; + Some((name, kind)) + } +} diff --git a/crates/krishiv-session/src/auth/mtls_tests.rs b/crates/krishiv-session/src/auth/mtls_tests.rs new file mode 100644 index 000000000..382b60f8c --- /dev/null +++ b/crates/krishiv-session/src/auth/mtls_tests.rs @@ -0,0 +1,582 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Client certificates, the DER walk, listener coherence and (feature `tls`) server TLS +//! (P3-T002 PR-14c; design §3.4, §3.5; ADR 0046 §6). Fixtures: `tests/fixtures/tls/README.md`. + +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::time::Instant; + +use krishiv_common::error::{ErrorClass, KrishivError}; +use krishiv_common::principal::PrincipalKind; + +use super::der::{CertNames, DerError}; +use super::{ + AuthConfig, AuthFailure, Credentials, Method, PrincipalFrom, Role, ServerAuth, Surface, + TlsConfig, check_listener, +}; + +const CLIENT: &[u8] = include_bytes!("../../tests/fixtures/tls/client.der"); +const CLIENT2: &[u8] = include_bytes!("../../tests/fixtures/tls/client2.der"); +const NOSAN: &[u8] = include_bytes!("../../tests/fixtures/tls/nosan.der"); +const SERVER: &[u8] = include_bytes!("../../tests/fixtures/tls/server.der"); + +const ETL: &str = "spiffe://krishiv.test/etl"; +const TOKEN: &str = "cccccccccccccccccccccccccccccccccccccccc"; + +fn fixture(name: &str) -> String { + format!("{}/tests/fixtures/tls/{name}", env!("CARGO_MANIFEST_DIR")) +} + +fn peer() -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 9)), 50_000) +} + +fn config(pairs: &[(&str, &str)]) -> Result { + AuthConfig::from_settings(pairs.iter().copied()) +} + +fn invalid_config(e: &KrishivError) -> bool { + e.error_class() == ErrorClass::KrishivInvalidConfigValue +} + +/// Client certificates on (`mode`), the principal from `from`, `etl` an operator. +fn mtls(mode: &str, from: &str) -> AuthConfig { + let ca = format!("file:{}", fixture("ca.pem")); + config(&[ + ("krishiv.auth.clientCertificates", mode), + ("krishiv.auth.clientCa", &ca), + ("krishiv.auth.mtls.principalFrom", from), + ("krishiv.auth.roles.operator", &format!("{ETL}, etl-job")), + ("krishiv.auth.roles.viewer", "reporting"), + ]) + .unwrap() +} + +#[test] +fn der_reads_subject_and_alt_names() { + let names = CertNames::from_der(CLIENT).unwrap(); + assert_eq!(names.common_names, ["etl-job"]); + assert_eq!(names.uris, [ETL]); + assert_eq!(names.dns, ["etl.krishiv.test"]); + + let two = CertNames::from_der(CLIENT2).unwrap(); + assert_eq!(two.common_names, ["a", "b"]); + assert_eq!(two.uris.len(), 2); + assert_eq!(two.dns, ["a.test", "b.test"]); + + let none = CertNames::from_der(NOSAN).unwrap(); + assert_eq!(none.common_names, ["reporting"]); + assert!(none.uris.is_empty() && none.dns.is_empty()); + + // An IP SAN is skipped, not refused. + let server = CertNames::from_der(SERVER).unwrap(); + assert_eq!(server.dns, ["localhost"]); +} + +#[test] +fn der_refuses_damaged_input_without_panicking() { + for len in 0..CLIENT.len() { + assert!(CertNames::from_der(&CLIENT[..len]).is_err(), "prefix {len}"); + } + let mut longer = CLIENT.to_vec(); + longer.push(0); + assert_eq!(CertNames::from_der(&longer), Err(DerError::Trailing)); + // Indefinite length (BER) at the outer sequence. + let mut indefinite = CLIENT.to_vec(); + indefinite[1] = 0x80; + assert_eq!(CertNames::from_der(&indefinite), Err(DerError::BadLength)); + // Every single-byte change either parses or fails; none panics. + for i in 0..CLIENT.len() { + let mut flipped = CLIENT.to_vec(); + flipped[i] ^= 0xff; + let _ = CertNames::from_der(&flipped); + } +} + +#[test] +fn der_refuses_a_non_minimal_length() { + // SEQUENCE, long form for a length under 128. + assert_eq!( + CertNames::from_der(&[0x30, 0x81, 0x01, 0x00]), + Err(DerError::BadLength) + ); + assert_eq!( + CertNames::from_der(&[0x30, 0x82, 0x00, 0x81]), + Err(DerError::BadLength) + ); +} + +#[test] +fn mtls_principal_rule() { + // ADR 0046 §6: exactly one value of the configured kind, no fallback to another kind. + let cases = [ + ("san-uri", CLIENT, Some(ETL)), + ("san-dns", CLIENT, Some("etl.krishiv.test")), + ("cn", CLIENT, Some("etl-job")), + ("san-uri", CLIENT2, None), + ("san-dns", CLIENT2, None), + ("cn", CLIENT2, None), + ("san-uri", NOSAN, None), + ("san-dns", NOSAN, None), + ("cn", NOSAN, Some("reporting")), + ("san-uri", &[0x30, 0x00], None), + ]; + for (from, cert, want) in cases { + let c = mtls("required", from); + assert_eq!( + c.mtls.principal_from, + match from { + "san-uri" => PrincipalFrom::SanUri, + "san-dns" => PrincipalFrom::SanDns, + _ => PrincipalFrom::Cn, + } + ); + let got = c.mtls.principal_of(cert).map(|(n, _)| n); + assert_eq!(got.as_deref(), want, "{from}"); + } +} + +#[test] +fn certificate_principals_are_services_unless_listed() { + let mut c = mtls("required", "san-uri"); + assert_eq!( + c.mtls.principal_of(CLIENT).unwrap().1, + PrincipalKind::Service + ); + c.mtls.user_principals.insert(ETL.to_owned()); + assert_eq!(c.mtls.principal_of(CLIENT).unwrap().1, PrincipalKind::User); +} + +#[test] +fn a_certificate_authenticates_with_its_listed_role() { + let auth = ServerAuth::new(&mtls("required", "san-uri")).unwrap(); + assert!(auth.is_enabled()); + let mut c = Credentials::new(peer(), Surface::Connect); + c.peer_leaf = Some(CLIENT); + let a = auth.authenticate_at(&c, Instant::now()).unwrap(); + assert_eq!( + (a.principal.name.as_str(), a.role, a.method), + (ETL, Some(Role::Operator), Method::Mtls) + ); + // A principal in no list has no role (everything is denied later). + let auth = ServerAuth::new(&mtls("required", "san-dns")).unwrap(); + let a = auth.authenticate_at(&c, Instant::now()).unwrap(); + assert_eq!(a.role, None); +} + +#[test] +fn a_certificate_without_one_principal_is_refused_and_counted() { + let auth = ServerAuth::new(&mtls("required", "san-uri")).unwrap(); + let mut c = Credentials::new(peer(), Surface::Admin); + c.peer_leaf = Some(CLIENT2); + assert_eq!( + auth.authenticate_at(&c, Instant::now()).unwrap_err(), + AuthFailure::Invalid + ); + let mtls_admin = auth + .failure_counts() + .into_iter() + .find(|f| f.surface == Surface::Admin && f.method == Method::Mtls) + .unwrap(); + assert_eq!(mtls_admin.count, 1); +} + +#[test] +fn required_certificates_never_fall_back_to_a_token() { + let dir = tempfile::tempdir().unwrap(); + let secret = secret_file(dir.path()); + let ca = format!("file:{}", fixture("ca.pem")); + let c = config(&[ + ("krishiv.auth.clientCertificates", "required"), + ("krishiv.auth.clientCa", &ca), + ("krishiv.auth.tokens.t.secret", &secret), + ("krishiv.auth.tokens.t.principal", "tok"), + ("krishiv.auth.tokens.t.role", "admin"), + ]) + .unwrap(); + let auth = ServerAuth::new(&c).unwrap(); + let mut cred = Credentials::new(peer(), Surface::Connect); + cred.bearer = Some(TOKEN); + assert_eq!( + auth.authenticate_at(&cred, Instant::now()).unwrap_err(), + AuthFailure::Missing + ); +} + +#[test] +fn optional_certificates_let_a_token_through() { + let dir = tempfile::tempdir().unwrap(); + let secret = secret_file(dir.path()); + let ca = format!("file:{}", fixture("ca.pem")); + let c = config(&[ + ("krishiv.auth.clientCertificates", "optional"), + ("krishiv.auth.clientCa", &ca), + ("krishiv.auth.tokens.t.secret", &secret), + ("krishiv.auth.tokens.t.principal", ETL), + ("krishiv.auth.tokens.t.role", "admin"), + ( + "krishiv.auth.tokens.u.secret", + &secret_file_named(dir.path(), "u", 'u'), + ), + ("krishiv.auth.tokens.u.principal", "someone-else"), + ("krishiv.auth.tokens.u.role", "viewer"), + ]) + .unwrap(); + let auth = ServerAuth::new(&c).unwrap(); + let mut cred = Credentials::new(peer(), Surface::Connect); + cred.bearer = Some(TOKEN); + let a = auth.authenticate_at(&cred, Instant::now()).unwrap(); + assert_eq!(a.method, Method::Token); + + // A certificate and a token for the same principal: the certificate decides (role from the + // lists, here none). + cred.peer_leaf = Some(CLIENT); + let a = auth.authenticate_at(&cred, Instant::now()).unwrap(); + assert_eq!((a.principal.name.as_str(), a.method), (ETL, Method::Mtls)); + + // A token naming another principal next to a certificate: refused (a confused gateway). + let other = "u".repeat(40); + cred.bearer = Some(&other); + assert_eq!( + auth.authenticate_at(&cred, Instant::now()).unwrap_err(), + AuthFailure::Invalid + ); + // So is an unusable `Authorization` header. + cred.bearer = None; + cred.malformed = true; + assert_eq!( + auth.authenticate_at(&cred, Instant::now()).unwrap_err(), + AuthFailure::Invalid + ); +} + +fn secret_file(dir: &std::path::Path) -> String { + secret_file_named(dir, "t", 'c') +} + +fn secret_file_named(dir: &std::path::Path, name: &str, fill: char) -> String { + let path = dir.join(name); + std::fs::write(&path, fill.to_string().repeat(40)).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + format!("file:{}", path.display()) +} + +#[test] +fn mtls_settings_are_checked_at_startup() { + let ca = format!("file:{}", fixture("ca.pem")); + let e = config(&[("krishiv.auth.clientCertificates", "required")]).unwrap_err(); + assert!( + invalid_config(&e) && e.to_string().contains("clientCa"), + "{e}" + ); + let e = config(&[("krishiv.auth.clientCa", &ca)]).unwrap_err(); + assert!(e.to_string().contains("is off"), "{e}"); + let e = config(&[("krishiv.auth.clientCertificates", "sometimes")]).unwrap_err(); + assert!(invalid_config(&e), "{e}"); + let e = config(&[("krishiv.auth.mtls.principalFrom", "email")]).unwrap_err(); + assert!(invalid_config(&e), "{e}"); + let e = config(&[("krishiv.auth.clientCa", "/no/file/prefix")]).unwrap_err(); + assert!(invalid_config(&e), "{e}"); +} + +#[test] +fn role_lists_give_one_role_per_principal() { + let e = config(&[ + ("krishiv.auth.roles.viewer", "a, b"), + ("krishiv.auth.roles.admin", "b"), + ]) + .unwrap_err(); + assert!(e.to_string().contains("exactly one role"), "{e}"); + let e = config(&[("krishiv.auth.roles.superuser", "a")]).unwrap_err(); + assert!(invalid_config(&e), "{e}"); + let e = config(&[("krishiv.auth.roles.Admin", "a")]).unwrap_err(); + assert!(invalid_config(&e), "{e}"); + // A key given twice keeps its last value. + let c = config(&[ + ("krishiv.auth.roles.viewer", "a"), + ("krishiv.auth.roles.viewer", "b"), + ("krishiv.auth.roles.admin", "a"), + ]) + .unwrap(); + assert_eq!(c.roles.role_of("a"), Some(Role::Admin)); + assert_eq!(c.roles.role_of("b"), Some(Role::Viewer)); + assert_eq!(c.roles.role_of("c"), None); +} + +#[test] +fn startup_incoherent_configs() { + let dir = tempfile::tempdir().unwrap(); + let secret = secret_file(dir.path()); + let tokens = config(&[ + ("krishiv.auth.tokens.t.secret", &secret), + ("krishiv.auth.tokens.t.principal", "p"), + ("krishiv.auth.tokens.t.role", "admin"), + ]) + .unwrap(); + let none = config(&[]).unwrap(); + let tls = TlsConfig::from_settings([ + ( + "krishiv.server.tls.certFile", + fixture("server.pem").as_str(), + ), + ("krishiv.server.tls.keyFile", fixture("server.key").as_str()), + ]) + .unwrap(); + let plain = TlsConfig::default(); + let public: SocketAddr = "0.0.0.0:15002".parse().unwrap(); + let local: SocketAddr = "127.0.0.1:15002".parse().unwrap(); + let local6: SocketAddr = "[::1]:15002".parse().unwrap(); + + // A non-loopback address with no authentication. + let e = check_listener(public, &none, &tls).unwrap_err(); + assert!( + e.to_string().contains("needs an authentication method"), + "{e}" + ); + // A non-loopback address with a token and no TLS. + let e = check_listener(public, &tokens, &plain).unwrap_err(); + assert!(e.to_string().contains("need TLS"), "{e}"); + // Client certificates without TLS, even on loopback. + let e = check_listener(local, &mtls("optional", "san-uri"), &plain).unwrap_err(); + assert!(e.to_string().contains("client certificates need"), "{e}"); + // Allowed: loopback with a token and no TLS (the stock client's localhost path), loopback + // with nothing, and a public address with tokens over TLS. + check_listener(local, &tokens, &plain).unwrap(); + check_listener(local6, &none, &plain).unwrap(); + check_listener(public, &tokens, &tls).unwrap(); + check_listener(public, &mtls("required", "san-uri"), &tls).unwrap(); +} + +#[test] +fn tls_settings_are_checked() { + let e = TlsConfig::from_settings([("krishiv.server.tls.certFile", "/x.pem")]).unwrap_err(); + assert!(e.to_string().contains("keyFile"), "{e}"); + let e = TlsConfig::from_settings([("krishiv.server.tls.keyFile", "/x.key")]).unwrap_err(); + assert!(e.to_string().contains("certFile"), "{e}"); + let e = TlsConfig::from_settings([("krishiv.server.tls.minVersion", "1.1")]).unwrap_err(); + assert!(invalid_config(&e), "{e}"); + let e = TlsConfig::from_settings([("krishiv.server.tls.ciphers", "x")]).unwrap_err(); + assert!(invalid_config(&e), "{e}"); + let c = TlsConfig::from_settings([ + ("krishiv.server.tls.certFile", "file:/a.pem"), + ("krishiv.server.tls.keyFile", "/a.key"), + ("krishiv.server.tls.minVersion", "1.3"), + ("krishiv.other", "ignored"), + ]) + .unwrap(); + assert!(c.is_on() && c.tls13_only); + assert_eq!(c.cert_file.as_deref(), Some(std::path::Path::new("/a.pem"))); +} + +/// Server TLS through rustls, with in-memory handshakes (no sockets). +#[cfg(feature = "tls")] +mod tls { + use std::path::{Path, PathBuf}; + use std::sync::Arc; + + use rustls::pki_types::pem::PemObject as _; + use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; + use rustls::{ClientConfig, ClientConnection, RootCertStore, ServerConnection}; + + use super::super::{MtlsConfig, ServerTls, TlsConfig}; + use super::{CLIENT, fixture, mtls}; + + /// The server's certificate and key copied to `dir` (key mode 0600), as settings. + fn server_files(dir: &Path, which: &str) -> (PathBuf, PathBuf) { + let cert = dir.join("server.pem"); + let key = dir.join("server.key"); + std::fs::copy(fixture(&format!("{which}.pem")), &cert).unwrap(); + std::fs::copy(fixture(&format!("{which}.key")), &key).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + (cert, key) + } + + fn tls_config(cert: &Path, key: &Path) -> TlsConfig { + TlsConfig { + cert_file: Some(cert.to_owned()), + key_file: Some(key.to_owned()), + tls13_only: false, + } + } + + fn provider() -> Arc { + Arc::new(rustls::crypto::aws_lc_rs::default_provider()) + } + + /// A client trusting the test CA, presenting `identity` (a fixture name) if given. + fn client(identity: Option<&str>) -> Arc { + let mut roots = RootCertStore::empty(); + roots + .add(CertificateDer::from_pem_file(fixture("ca.pem")).unwrap()) + .unwrap(); + let b = ClientConfig::builder_with_provider(provider()) + .with_safe_default_protocol_versions() + .unwrap() + .with_root_certificates(roots); + Arc::new(match identity { + None => b.with_no_client_auth(), + Some(name) => { + let chain = + vec![CertificateDer::from_pem_file(fixture(&format!("{name}.pem"))).unwrap()]; + let key = PrivateKeyDer::from_pem_file(fixture(&format!("{name}.key"))).unwrap(); + b.with_client_auth_cert(chain, key).unwrap() + } + }) + } + + /// One handshake in memory. On success, the server's view of the client's leaf and the + /// leaf the client saw from the server. + fn handshake( + server: &ServerTls, + client: Arc, + ) -> Result<(Option>, Vec), String> { + let mut s = ServerConnection::new(server.server_config()).map_err(|e| e.to_string())?; + let name = ServerName::try_from("localhost").map_err(|e| e.to_string())?; + let mut c = ClientConnection::new(client, name).map_err(|e| e.to_string())?; + for _ in 0..20 { + if !c.is_handshaking() && !s.is_handshaking() { + break; + } + let mut buf = Vec::new(); + while c.wants_write() { + c.write_tls(&mut buf).map_err(|e| e.to_string())?; + } + if !buf.is_empty() { + s.read_tls(&mut buf.as_slice()).map_err(|e| e.to_string())?; + s.process_new_packets().map_err(|e| e.to_string())?; + } + let mut buf = Vec::new(); + while s.wants_write() { + s.write_tls(&mut buf).map_err(|e| e.to_string())?; + } + if !buf.is_empty() { + c.read_tls(&mut buf.as_slice()).map_err(|e| e.to_string())?; + c.process_new_packets().map_err(|e| e.to_string())?; + } + } + if c.is_handshaking() || s.is_handshaking() { + return Err("handshake did not finish".to_owned()); + } + let peer = s + .peer_certificates() + .and_then(|chain| chain.first()) + .map(|leaf| leaf.as_ref().to_vec()); + let served = c + .peer_certificates() + .and_then(|chain| chain.first()) + .map(|leaf| leaf.as_ref().to_vec()) + .unwrap_or_default(); + Ok((peer, served)) + } + + #[test] + fn tls_serves_and_reloads_the_certificate() { + let dir = tempfile::tempdir().unwrap(); + let (cert, key) = server_files(dir.path(), "server"); + let server = ServerTls::new(&tls_config(&cert, &key), &MtlsConfig::default()) + .unwrap() + .unwrap(); + let first = handshake(&server, client(None)).unwrap(); + assert_eq!(first.1, super::SERVER); + + // Swap the files and reload: new handshakes get the new certificate. + server_files(dir.path(), "server2"); + server.reload().unwrap(); + let second = handshake(&server, client(None)).unwrap(); + assert_eq!( + second.1, + include_bytes!("../../tests/fixtures/tls/server2.der") + ); + + // Material that fails to load leaves the current one serving. + std::fs::write(&cert, "not a certificate").unwrap(); + let e = server.reload().unwrap_err(); + assert!(e.to_string().contains("certFile"), "{e}"); + let third = handshake(&server, client(None)).unwrap(); + assert_eq!(third.1, second.1); + } + + #[test] + fn required_client_certificates_are_verified_at_the_handshake() { + let dir = tempfile::tempdir().unwrap(); + let (cert, key) = server_files(dir.path(), "server"); + let c = mtls("required", "san-uri"); + let server = ServerTls::new(&tls_config(&cert, &key), &c.mtls) + .unwrap() + .unwrap(); + // The verified leaf reaches the principal rule. + let (leaf, _) = handshake(&server, client(Some("client"))).unwrap(); + let leaf = leaf.unwrap(); + assert_eq!(leaf, CLIENT); + assert_eq!(c.mtls.principal_of(&leaf).unwrap().0, super::ETL); + // No certificate, or an expired one: the handshake fails. + assert!(handshake(&server, client(None)).is_err()); + let e = handshake(&server, client(Some("expired"))).unwrap_err(); + assert!(e.to_lowercase().contains("expired"), "{e}"); + // A certificate of the same CA without the clientAuth usage (the server's own). + assert!(handshake(&server, client(Some("server"))).is_err()); + } + + #[test] + fn optional_client_certificates_allow_none() { + let dir = tempfile::tempdir().unwrap(); + let (cert, key) = server_files(dir.path(), "server"); + let c = mtls("optional", "san-uri"); + let server = ServerTls::new(&tls_config(&cert, &key), &c.mtls) + .unwrap() + .unwrap(); + let (leaf, _) = handshake(&server, client(None)).unwrap(); + assert!(leaf.is_none()); + let (leaf, _) = handshake(&server, client(Some("client"))).unwrap(); + assert_eq!(leaf.as_deref(), Some(CLIENT)); + } + + #[test] + fn tls_material_is_checked_at_startup() { + let dir = tempfile::tempdir().unwrap(); + let (cert, key) = server_files(dir.path(), "server"); + // A key readable by others. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o644)).unwrap(); + let e = ServerTls::new(&tls_config(&cert, &key), &MtlsConfig::default()).unwrap_err(); + assert!(e.to_string().contains("readable by group or others"), "{e}"); + std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + // A key that does not belong to the certificate. + std::fs::copy(fixture("server2.key"), &key).unwrap(); + let e = ServerTls::new(&tls_config(&cert, &key), &MtlsConfig::default()).unwrap_err(); + assert!(e.to_string().contains("does not match"), "{e}"); + // TLS off. + assert!( + ServerTls::new(&TlsConfig::default(), &MtlsConfig::default()) + .unwrap() + .is_none() + ); + // TLS 1.3 only refuses a 1.2-only client. + server_files(dir.path(), "server"); + let mut only13 = tls_config(&cert, &key); + only13.tls13_only = true; + let server = ServerTls::new(&only13, &MtlsConfig::default()) + .unwrap() + .unwrap(); + let mut roots = RootCertStore::empty(); + roots + .add(CertificateDer::from_pem_file(fixture("ca.pem")).unwrap()) + .unwrap(); + let old = ClientConfig::builder_with_provider(provider()) + .with_protocol_versions(&[&rustls::version::TLS12]) + .unwrap() + .with_root_certificates(roots) + .with_no_client_auth(); + assert!(handshake(&server, Arc::new(old)).is_err()); + } +} diff --git a/crates/krishiv-session/src/auth/secret_ref.rs b/crates/krishiv-session/src/auth/secret_ref.rs index 67faf819a..836fb3651 100644 --- a/crates/krishiv-session/src/auth/secret_ref.rs +++ b/crates/krishiv-session/src/auth/secret_ref.rs @@ -70,36 +70,11 @@ impl SecretRef { ) }), Self::File(path) => { - use std::io::Read as _; - let shown = format!("file:{}", path.display()); - let unreadable = |e: std::io::Error| { - invalid(key, &shown, &format!("cannot be read: {}", e.kind())) - }; - // The path is checked before it is opened, so a FIFO or device is refused - // instead of blocking startup in `open`; the handle is checked again, so the file - // checked is the file read. - check_private(key, &shown, &std::fs::metadata(path).map_err(unreadable)?)?; - let file = std::fs::File::open(path).map_err(unreadable)?; - check_private(key, &shown, &file.metadata().map_err(unreadable)?)?; - // A token plus one line break (CRLF) at most: one byte past that refuses the file - // here, before the line break is removed, so a longer file is never cut to fit. + // A token plus one line break (CRLF) at most: a longer file is refused before + // the line break is removed, so it is never cut to fit. let most = super::tokens::MAX_SECRET_BYTES + 2; - let mut bytes = Vec::new(); - file.take(most as u64 + 1) - .read_to_end(&mut bytes) - .map_err(unreadable)?; - if bytes.len() > most { - return Err(invalid( - key, - &shown, - &format!( - "the file is longer than a {}-byte token and one line break", - super::tokens::MAX_SECRET_BYTES - ), - )); - } - let mut text = String::from_utf8(bytes) - .map_err(|_| invalid(key, &shown, "the file is not UTF-8"))?; + let secret = read_private_file(key, path, most)?; + let mut text = secret.expose_secret().to_owned(); if text.ends_with('\n') { text.pop(); if text.ends_with('\r') { @@ -119,6 +94,39 @@ impl SecretRef { } } +/// The contents of the private file `path` (a secret, or a TLS key), at most `most` bytes. +/// +/// The path is checked before it is opened, so a FIFO or device is refused instead of blocking +/// startup in `open`; the handle is checked again, so the file checked is the file read. A file +/// readable by group or others, or longer than `most`, is refused. +pub(crate) fn read_private_file( + key: &str, + path: &std::path::Path, + most: usize, +) -> Result { + use std::io::Read as _; + let shown = format!("file:{}", path.display()); + let unreadable = + |e: std::io::Error| invalid(key, &shown, &format!("cannot be read: {}", e.kind())); + check_private(key, &shown, &std::fs::metadata(path).map_err(unreadable)?)?; + let file = std::fs::File::open(path).map_err(unreadable)?; + check_private(key, &shown, &file.metadata().map_err(unreadable)?)?; + let mut bytes = Vec::new(); + file.take(most as u64 + 1) + .read_to_end(&mut bytes) + .map_err(unreadable)?; + if bytes.len() > most { + return Err(invalid( + key, + &shown, + &format!("the file is longer than {most} bytes"), + )); + } + let text = + String::from_utf8(bytes).map_err(|_| invalid(key, &shown, "the file is not UTF-8"))?; + Ok(SecretString::new(text)) +} + /// Refuses a secret file that group or others may read. #[cfg(unix)] fn check_private(key: &str, shown: &str, meta: &std::fs::Metadata) -> Result<(), KrishivError> { diff --git a/crates/krishiv-session/src/auth/server.rs b/crates/krishiv-session/src/auth/server.rs index 0a98ad6bf..4ec2d21ee 100644 --- a/crates/krishiv-session/src/auth/server.rs +++ b/crates/krishiv-session/src/auth/server.rs @@ -1,9 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 //! [`ServerAuth`], the one authenticator of a server (design §3.1). //! -//! The chain, in a fixed order: a throttled peer is refused first; then the bearer token goes to -//! the static tokens (OIDC and client certificates join in later units, ahead of it as the -//! design orders them); no credential is [`AuthFailure::Missing`]. Every failure writes one log +//! The chain, in a fixed order: a throttled peer is refused first; then a verified client +//! certificate names the principal (when client certificates are on; a bearer token sent with it +//! must name the same principal); then the bearer token goes to the static tokens (OIDC joins in +//! a later unit); no credential is [`AuthFailure::Missing`]. Every failure writes one log //! record (target `krishiv::auth`: surface, method, peer, failure; never the credential) and //! counts once per `(surface, method)`. @@ -12,8 +13,10 @@ use std::time::Instant; use futures::future::BoxFuture; use krishiv_common::error::KrishivError; +use krishiv_common::principal::Principal; -use super::config::{AuthConfig, SPARK_TOKEN_NAME}; +use super::config::{AuthConfig, RoleLists, SPARK_TOKEN_NAME}; +use super::mtls::{ClientCertificates, MtlsConfig}; use super::throttle::FailureThrottle; use super::tokens::StaticTokens; use super::{AuthFailure, Authenticated, Authenticator, Credentials, Method, Surface}; @@ -37,6 +40,8 @@ pub struct FailureCount { #[derive(Debug)] pub struct ServerAuth { tokens: StaticTokens, + mtls: MtlsConfig, + roles: RoleLists, throttle: FailureThrottle, /// Indexed by `surface * Method::ALL.len() + method`. failures: [AtomicU64; Surface::ALL.len() * Method::ALL.len()], @@ -59,6 +64,8 @@ impl ServerAuth { } Ok(Self { tokens, + mtls: config.mtls.clone(), + roles: config.roles.clone(), throttle: FailureThrottle::new(config.throttle), failures: std::array::from_fn(|_| AtomicU64::new(0)), }) @@ -68,7 +75,7 @@ impl ServerAuth { /// (loopback only; the listener enforces that). #[must_use] pub fn is_enabled(&self) -> bool { - !self.tokens.is_empty() + !self.tokens.is_empty() || self.mtls.is_on() } /// Authenticates `credentials` at `now` (the clock is a parameter for tests). @@ -84,6 +91,16 @@ impl ServerAuth { if self.throttle.is_blocked(ip, now) { return Err(self.fail(credentials, Method::Throttle, AuthFailure::Throttled)); } + if self.mtls.is_on() { + if let Some(leaf) = credentials.peer_leaf { + return self.authenticate_certificate(credentials, leaf, now); + } + if self.mtls.client_certificates == ClientCertificates::Required { + // The handshake refuses a client without a certificate; a transport that did + // not pass the leaf through must not fall back to a token. + return Err(self.fail(credentials, Method::Mtls, AuthFailure::Missing)); + } + } let token = match credentials.bearer { Some(token) => Some(token), None if credentials.malformed => None, @@ -110,6 +127,39 @@ impl ServerAuth { } } + /// A client with a verified certificate: the configured rule names the principal. A bearer + /// token sent as well must name the same principal (a confused gateway is refused). + fn authenticate_certificate( + &self, + credentials: &Credentials<'_>, + leaf: &[u8], + now: Instant, + ) -> Result { + let ip = credentials.peer.ip(); + let named = self.mtls.principal_of(leaf); + let agrees = |name: &str| match (credentials.bearer, credentials.malformed) { + (None, false) => true, + (Some(t), _) if t.len() <= MAX_TOKEN_BYTES => { + self.tokens.lookup(t).is_some_and(|(p, _)| p.name == name) + } + _ => false, + }; + match named { + Some((name, kind)) if agrees(&name) => { + let role = self.roles.role_of(&name); + Ok(Authenticated::new( + Principal::new(name, kind), + role, + Method::Mtls, + )) + } + _ => { + self.throttle.record_failure(ip, now); + Err(self.fail(credentials, Method::Mtls, AuthFailure::Invalid)) + } + } + } + /// The failure counters, every `(surface, method)` pair in order. #[must_use] pub fn failure_counts(&self) -> Vec { diff --git a/crates/krishiv-session/src/auth/tls.rs b/crates/krishiv-session/src/auth/tls.rs new file mode 100644 index 000000000..b36c85b5c --- /dev/null +++ b/crates/krishiv-session/src/auth/tls.rs @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Server TLS (design §3.5, threat model T-T1): the rustls configuration both listeners serve, +//! on the `aws-lc-rs` provider (no OpenSSL, ADR 0028). +//! +//! - `krishiv.server.tls.certFile` / `keyFile`: PEM files; the key file must not be readable by +//! group or others. `krishiv.server.tls.minVersion`: `1.2` (default) or `1.3`. +//! - Client certificates ([`MtlsConfig`]): rustls checks the chain against the client CA and +//! the validity dates at the handshake; with `optional` a client may present none. +//! - [`ServerTls::reload`] re-reads the certificate and key (the binary calls it on `SIGHUP`): +//! new handshakes get the new material, connections already up keep theirs, and material that +//! fails to load leaves the old one serving. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; + +use krishiv_common::error::KrishivError; +use rustls::crypto::CryptoProvider; +use rustls::pki_types::pem::PemObject as _; +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use rustls::server::{ClientHello, ResolvesServerCert, WebPkiClientVerifier}; +use rustls::sign::CertifiedKey; +use rustls::{RootCertStore, ServerConfig}; + +use super::mtls::{ClientCertificates, MtlsConfig}; +use super::secret_ref::{invalid, read_private_file}; +use super::tls_config::{CERT_KEY, KEY_KEY, MIN_VERSION_KEY, TlsConfig}; + +/// The longest private key file read (a PEM RSA-8192 key is about 6.3 KiB). +const MAX_KEY_FILE_BYTES: usize = 64 * 1024; + +/// The certificate the server presents, swapped whole on reload. +#[derive(Debug)] +struct Current { + key: RwLock>, +} + +impl ResolvesServerCert for Current { + fn resolve(&self, _hello: ClientHello<'_>) -> Option> { + // A poisoned lock still holds a whole `Arc`: serve it. + let guard = self + .key + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + Some(Arc::clone(&guard)) + } +} + +/// The server's TLS: the rustls configuration and the material behind it. +#[derive(Debug, Clone)] +pub struct ServerTls { + config: Arc, + current: Arc, + cert_file: PathBuf, + key_file: PathBuf, + provider: Arc, +} + +impl ServerTls { + /// Loads the certificate, key and (when client certificates are on) the client CA. + /// `None` when TLS is off. + /// + /// # Errors + /// `KRISHIV_INVALID_CONFIG_VALUE` when a file cannot be read or parsed, the key file is + /// readable by group or others, the key does not match the certificate, or the client CA + /// holds no usable certificate. + pub fn new(tls: &TlsConfig, mtls: &MtlsConfig) -> Result, KrishivError> { + let (Some(cert_file), Some(key_file)) = (&tls.cert_file, &tls.key_file) else { + return Ok(None); + }; + let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); + let current = Arc::new(Current { + key: RwLock::new(Arc::new(load_key(cert_file, key_file, &provider)?)), + }); + let versions: &[&rustls::SupportedProtocolVersion] = if tls.tls13_only { + &[&rustls::version::TLS13] + } else { + &[&rustls::version::TLS13, &rustls::version::TLS12] + }; + let builder = ServerConfig::builder_with_provider(Arc::clone(&provider)) + .with_protocol_versions(versions) + .map_err(|e| invalid(MIN_VERSION_KEY, "", &e.to_string()))?; + let builder = match (mtls.client_certificates, &mtls.client_ca) { + (ClientCertificates::Off, _) | (_, None) => builder.with_no_client_auth(), + (mode, Some(ca)) => { + let roots = Arc::new(client_roots(ca)?); + let verifier = + WebPkiClientVerifier::builder_with_provider(roots, Arc::clone(&provider)); + let verifier = if mode == ClientCertificates::Optional { + verifier.allow_unauthenticated() + } else { + verifier + }; + let verifier = verifier + .build() + .map_err(|e| invalid("krishiv.auth.clientCa", &shown(ca), &e.to_string()))?; + builder.with_client_cert_verifier(verifier) + } + }; + let mut config = + builder.with_cert_resolver(Arc::clone(¤t) as Arc); + // gRPC needs HTTP/2; the admin listener also speaks HTTP/1.1. + config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + Ok(Some(Self { + config: Arc::new(config), + current, + cert_file: cert_file.clone(), + key_file: key_file.clone(), + provider, + })) + } + + /// The configuration both listeners serve. + #[must_use] + pub fn server_config(&self) -> Arc { + Arc::clone(&self.config) + } + + /// Re-reads the certificate and key files. New handshakes get the new material; on an + /// error the old material keeps serving. + /// + /// # Errors + /// As [`ServerTls::new`] for the certificate and key. + pub fn reload(&self) -> Result<(), KrishivError> { + let key = load_key(&self.cert_file, &self.key_file, &self.provider)?; + let mut guard = self + .current + .key + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *guard = Arc::new(key); + Ok(()) + } +} + +fn shown(path: &Path) -> String { + format!("file:{}", path.display()) +} + +/// The certificate chain and key, checked to belong together. +fn load_key( + cert_file: &Path, + key_file: &Path, + provider: &CryptoProvider, +) -> Result { + let pem = std::fs::read(cert_file).map_err(|e| { + invalid( + CERT_KEY, + &shown(cert_file), + &format!("cannot be read: {}", e.kind()), + ) + })?; + let chain = CertificateDer::pem_slice_iter(&pem) + .collect::, _>>() + .map_err(|_| invalid(CERT_KEY, &shown(cert_file), "not a PEM certificate chain"))?; + if chain.is_empty() { + return Err(invalid(CERT_KEY, &shown(cert_file), "holds no certificate")); + } + let key_pem = read_private_file(KEY_KEY, key_file, MAX_KEY_FILE_BYTES)?; + let key = PrivateKeyDer::from_pem_slice(key_pem.expose_secret().as_bytes()) + .map_err(|_| invalid(KEY_KEY, &shown(key_file), "not a PEM private key"))?; + CertifiedKey::from_der(chain, key, provider).map_err(|e| { + invalid( + KEY_KEY, + &shown(key_file), + &format!("does not match the certificate or is not supported: {e}"), + ) + }) +} + +/// The client CA certificates. +fn client_roots(ca: &Path) -> Result { + let key = "krishiv.auth.clientCa"; + let pem = std::fs::read(ca) + .map_err(|e| invalid(key, &shown(ca), &format!("cannot be read: {}", e.kind())))?; + let mut roots = RootCertStore::empty(); + for cert in CertificateDer::pem_slice_iter(&pem) { + let cert = cert.map_err(|_| invalid(key, &shown(ca), "not a PEM certificate file"))?; + roots.add(cert).map_err(|e| { + invalid( + key, + &shown(ca), + &format!("not a usable CA certificate: {e}"), + ) + })?; + } + if roots.is_empty() { + return Err(invalid(key, &shown(ca), "holds no certificate")); + } + Ok(roots) +} diff --git a/crates/krishiv-session/src/auth/tls_config.rs b/crates/krishiv-session/src/auth/tls_config.rs new file mode 100644 index 000000000..dd01ab15d --- /dev/null +++ b/crates/krishiv-session/src/auth/tls_config.rs @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +//! The `krishiv.server.tls.*` settings (design §3.5) and the startup check that a listener's +//! address, authentication and TLS make sense together (threat model §6 item 9). Read without +//! the `tls` feature, so a build without TLS still refuses a TLS configuration it cannot serve. + +use std::net::SocketAddr; +use std::path::PathBuf; + +use krishiv_common::error::KrishivError; + +use super::config::AuthConfig; +use super::secret_ref::invalid; + +pub(crate) const CERT_KEY: &str = "krishiv.server.tls.certFile"; +pub(crate) const KEY_KEY: &str = "krishiv.server.tls.keyFile"; +pub(crate) const MIN_VERSION_KEY: &str = "krishiv.server.tls.minVersion"; + +/// The `krishiv.server.tls.*` settings. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TlsConfig { + /// The PEM certificate chain, leaf first. + pub cert_file: Option, + /// The PEM private key. + pub key_file: Option, + /// Whether TLS 1.2 is refused (`minVersion = 1.3`). + pub tls13_only: bool, +} + +impl TlsConfig { + /// Reads the `krishiv.server.tls.*` settings of `settings` (other keys are ignored). + /// + /// # Errors + /// `KRISHIV_INVALID_CONFIG_VALUE` for an unknown `krishiv.server.tls.*` key, a bad + /// `minVersion`, or a certificate without a key (or a key without a certificate). + pub fn from_settings<'a>( + settings: impl IntoIterator, + ) -> Result { + let mut out = Self::default(); + for (key, raw) in settings { + match key { + CERT_KEY => out.cert_file = Some(path(key, raw)?), + KEY_KEY => out.key_file = Some(path(key, raw)?), + MIN_VERSION_KEY => { + out.tls13_only = match raw.trim() { + "1.2" => false, + "1.3" => true, + _ => return Err(invalid(key, raw, "expected 1.2 or 1.3")), + }; + } + _ if key.starts_with("krishiv.server.tls.") => { + return Err(invalid(key, key, "not a known TLS setting")); + } + _ => {} + } + } + match (&out.cert_file, &out.key_file) { + (Some(_), None) => Err(invalid( + KEY_KEY, + "", + "required with krishiv.server.tls.certFile", + )), + (None, Some(_)) => Err(invalid( + CERT_KEY, + "", + "required with krishiv.server.tls.keyFile", + )), + _ => Ok(out), + } + } + + /// Whether the listeners serve TLS. + #[must_use] + pub fn is_on(&self) -> bool { + self.cert_file.is_some() + } +} + +/// A plain path or `file:/path`. +fn path(key: &str, raw: &str) -> Result { + let p = raw.strip_prefix("file:").unwrap_or(raw).trim(); + if p.is_empty() { + return Err(invalid(key, raw, "a file path is required")); + } + Ok(PathBuf::from(p)) +} + +/// Refuses a listener on `addr` whose authentication and TLS do not fit together (design §3.5): +/// - a non-loopback address with no authentication method; +/// - a non-loopback address with a bearer method and no TLS (a stock client would send the token +/// over TLS and could not connect; a custom one would send it in clear); +/// - client certificates without TLS. +/// +/// Loopback with a token and no TLS is allowed: that is the stock client's `localhost` path. +/// +/// # Errors +/// `KRISHIV_INVALID_CONFIG_VALUE` naming the setting to change. +pub fn check_listener( + addr: SocketAddr, + auth: &AuthConfig, + tls: &TlsConfig, +) -> Result<(), KrishivError> { + if auth.mtls.is_on() && !tls.is_on() { + return Err(invalid( + "krishiv.auth.clientCertificates", + auth.mtls.client_certificates.as_str(), + "client certificates need krishiv.server.tls.certFile and keyFile", + )); + } + if addr.ip().is_loopback() { + return Ok(()); + } + let shown = addr.to_string(); + if !auth.is_configured() { + return Err(invalid( + "krishiv.server.host", + &shown, + "a non-loopback address needs an authentication method (krishiv.auth.*)", + )); + } + if !auth.tokens.is_empty() && !tls.is_on() { + return Err(invalid( + "krishiv.server.host", + &shown, + "bearer tokens on a non-loopback address need TLS (krishiv.server.tls.certFile)", + )); + } + Ok(()) +} diff --git a/crates/krishiv-session/tests/fixtures/tls/README.md b/crates/krishiv-session/tests/fixtures/tls/README.md new file mode 100644 index 000000000..948815284 --- /dev/null +++ b/crates/krishiv-session/tests/fixtures/tls/README.md @@ -0,0 +1,13 @@ +# TLS test fixtures (P3-T002 PR-14c) + +Test-only certificates and keys, made with the `openssl` CLI (not a dependency) on 2026-11-12; +never use them anywhere else. EC P-256, valid until 2126 except `expired.pem`. + +- `ca.pem`: the test CA (`CN=krishiv test CA`), signs everything below. Its key is not kept. +- `server.pem`/`.key`/`.der`, `server2.*`: `CN=localhost` / `CN=localhost-2`, SAN `DNS:localhost`, `IP:127.0.0.1`, serverAuth. +- `client.*`: `O=krishiv, CN=etl-job`, SAN `URI:spiffe://krishiv.test/etl`, `DNS:etl.krishiv.test`, clientAuth. +- `client2.*`: two CNs (`a`, `b`), two URIs and two DNS names: refused by every `principalFrom` rule. +- `nosan.*`: `CN=reporting`, no subjectAltName. +- `expired.*`: SAN URI `spiffe://krishiv.test/old`, valid 2020-01-01 to 2020-01-02. + +Key files are mode 0600 in the tests (git does not keep modes; the tests copy them). diff --git a/crates/krishiv-session/tests/fixtures/tls/ca.pem b/crates/krishiv-session/tests/fixtures/tls/ca.pem new file mode 100644 index 000000000..505515d72 --- /dev/null +++ b/crates/krishiv-session/tests/fixtures/tls/ca.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBnDCCAUGgAwIBAgIUPZ2kYx65O5cfKBcJjM13iK5t21EwCgYIKoZIzj0EAwIw +GjEYMBYGA1UEAwwPa3Jpc2hpdiB0ZXN0IENBMCAXDTI2MTExMjIwMDkwNloYDzIx +MjYxMDE5MjAwOTA2WjAaMRgwFgYDVQQDDA9rcmlzaGl2IHRlc3QgQ0EwWTATBgcq +hkjOPQIBBggqhkjOPQMBBwNCAASyxfurnob2lbbBEyOopTJ8n/S0/VLGtQaTbYhb +lH5Qbcd/6dt2ovAv0tKUUI7OSWk7+xFR5ypWMgsaW7YIvleuo2MwYTAdBgNVHQ4E +FgQUrphYx3UgW7TjtwLsgOPkxuBZS0owHwYDVR0jBBgwFoAUrphYx3UgW7TjtwLs +gOPkxuBZS0owDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZI +zj0EAwIDSQAwRgIhAPYRQNzZPbGTdI4ijLKze6eNEJK3pOh+ubeDZhRo5GKjAiEA +zULZvVRf+A0Emb1v9m1yLazlTCHvTpxak8HZB1Wfw9U= +-----END CERTIFICATE----- diff --git a/crates/krishiv-session/tests/fixtures/tls/client.der b/crates/krishiv-session/tests/fixtures/tls/client.der new file mode 100644 index 0000000000000000000000000000000000000000..7763c479be1b5bfdd415c5db086204d85eeaf516 GIT binary patch literal 472 zcmXqLV!UF|#8|n2nTe5!NrdC7wdSO|7a|guREMs-ro?gmW^aT67aNCGo5wj@7G@>` zDMJYZF*fE<7G@s)?4r!#jLb5HlGNf71!qSC1#w;@Gebi|BO?O?O9Qhg34S9(pn!p) zB~-vb#ZbV252TxmhaIXLCc@0ao?4Qlo0Xqr5NROH#tycTi4kfwGb1~*69Y>)kLt15 zp!m&`UN5}0{Yi*+!tJEo^^c1dP1F6V%Fmm}^UvFYz?qX+VhKF}#m0zg6! zSrq6jpgvU=9#DvAvoW%=vI9dz79_~WBE}-(-6>$|r=Kbn>~LCa&+C2n%BEj-GLQ#J zE3*JYtwChnjELi<3ej60Z)bYb@c7BG2a(=h$f3*Z$zb5dq{uL_uYq;Gr+KJ}>9(yZ z5}%a{&eiSbYHg2xZ}4|}cb1qnP|4TJY_SqfX5KaIQ`K$$_9zG0%Ww23%sww%{OWjJ H)0#T~Rs))6 literal 0 HcmV?d00001 diff --git a/crates/krishiv-session/tests/fixtures/tls/client.key b/crates/krishiv-session/tests/fixtures/tls/client.key new file mode 100644 index 000000000..16d4d3517 --- /dev/null +++ b/crates/krishiv-session/tests/fixtures/tls/client.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgVf6JdXaRLXTxeax3 +kWa4/JyF47CxRMDD2/0qdGpZjzehRANCAARXDCXGXVJfs5Lrodq35FQrYNtiba/j +cqKWLfUlDw1uDP4+5J7xQLzo+RKLd23B/OTYB8JXOKLauWf/nX8Dlmgu +-----END PRIVATE KEY----- diff --git a/crates/krishiv-session/tests/fixtures/tls/client.pem b/crates/krishiv-session/tests/fixtures/tls/client.pem new file mode 100644 index 000000000..b083855d2 --- /dev/null +++ b/crates/krishiv-session/tests/fixtures/tls/client.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIB1DCCAXmgAwIBAgIUCNU7KZLd0FhhpHtVqdYiCNfZjVgwCgYIKoZIzj0EAwIw +GjEYMBYGA1UEAwwPa3Jpc2hpdiB0ZXN0IENBMCAXDTI2MTExMjIwMDkwNloYDzIx +MjYxMDE5MjAwOTA2WjAkMRAwDgYDVQQKDAdrcmlzaGl2MRAwDgYDVQQDDAdldGwt +am9iMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEVwwlxl1SX7OS66Hat+RUK2Db +Ym2v43Kili31JQ8Nbgz+PuSe8UC86PkSi3dtwfzk2AfCVzii2rln/51/A5ZoLqOB +kDCBjTA2BgNVHREELzAthhlzcGlmZmU6Ly9rcmlzaGl2LnRlc3QvZXRsghBldGwu +a3Jpc2hpdi50ZXN0MBMGA1UdJQQMMAoGCCsGAQUFBwMCMB0GA1UdDgQWBBRLiRA1 +Ti9lGlNAyxa8677edpfTQjAfBgNVHSMEGDAWgBSumFjHdSBbtOO3AuyA4+TG4FlL +SjAKBggqhkjOPQQDAgNJADBGAiEAkY6ABZ9JN1U0Nba1JBjzInDOfr8KhYdb7zD9 +t4tqFjsCIQD10wZdGEI2S3wHlSc8/YwjUj8fsUhxa88Tc+rHfoKs3A== +-----END CERTIFICATE----- diff --git a/crates/krishiv-session/tests/fixtures/tls/client2.der b/crates/krishiv-session/tests/fixtures/tls/client2.der new file mode 100644 index 0000000000000000000000000000000000000000..444cac0f44ac8d58210eda73ffeef48a7e4b9db9 GIT binary patch literal 480 zcmXqLV!UI}#MrcenTe5!NrdC7wdSO|7a|guREMs-ro?gmW^bec7aNCGo5wj@7G@>` zDMJYZF*fE<7G@s)?4r!#jLb5HlGNf71!qSC1#w;@Gebi|BO?O?O9Qhg34S9(pn!p) zB~-vb!jQ{=1EQNT5s95-5NROH#tycHi4kfYGb1~*69Wrh@Zl8R$+b>9|5(*+&RB5y z>#@pE<&Nc*RwYg@%!j!fCcWRjxU}-U<5vgA;OzuMhQs9>@p!iMS+^n%EM-N}AY`z%I_qcsiFxt#;>ua?@8_SEW3hiV z;cAA#fz7_5KMKqiF)1=U`YH5kx@e80$IJ956&<%GT|C`waKy4~;WghPeU7GM@c@qL BqN)G@ literal 0 HcmV?d00001 diff --git a/crates/krishiv-session/tests/fixtures/tls/client2.key b/crates/krishiv-session/tests/fixtures/tls/client2.key new file mode 100644 index 000000000..6db3d7b65 --- /dev/null +++ b/crates/krishiv-session/tests/fixtures/tls/client2.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQggcjuHMq9gglvFcAD +001C1slvt1INDqL+Kfazf5+biFehRANCAAQOU8NkDZN9Qrn8On6zaKDT9cZ5VSOI +p3k6dEJEA8MLgJLvt6N1ee9B9UBBc3Z+YfP8GeWVsjB44LdMZwfbIW2s +-----END PRIVATE KEY----- diff --git a/crates/krishiv-session/tests/fixtures/tls/client2.pem b/crates/krishiv-session/tests/fixtures/tls/client2.pem new file mode 100644 index 000000000..6f7b8427d --- /dev/null +++ b/crates/krishiv-session/tests/fixtures/tls/client2.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIB3DCCAYKgAwIBAgIUCNU7KZLd0FhhpHtVqdYiCNfZjVkwCgYIKoZIzj0EAwIw +GjEYMBYGA1UEAwwPa3Jpc2hpdiB0ZXN0IENBMCAXDTI2MTExMjIwMDkwNloYDzIx +MjYxMDE5MjAwOTA2WjAYMQowCAYDVQQDDAFhMQowCAYDVQQDDAFiMFkwEwYHKoZI +zj0CAQYIKoZIzj0DAQcDQgAEDlPDZA2TfUK5/Dp+s2ig0/XGeVUjiKd5OnRCRAPD +C4CS77ejdXnvQfVAQXN2fmHz/BnllbIweOC3TGcH2yFtrKOBpTCBojBLBgNVHREE +RDBChhdzcGlmZmU6Ly9rcmlzaGl2LnRlc3QvYYYXc3BpZmZlOi8va3Jpc2hpdi50 +ZXN0L2KCBmEudGVzdIIGYi50ZXN0MBMGA1UdJQQMMAoGCCsGAQUFBwMCMB0GA1Ud +DgQWBBRi3cOxRi+X5LfhA1fA2XWLLxwNsjAfBgNVHSMEGDAWgBSumFjHdSBbtOO3 +AuyA4+TG4FlLSjAKBggqhkjOPQQDAgNIADBFAiAtZol/mvxc6OXRqZrv32/LHgQ/ +6pDVaDDAs01V+HA2ogIhAOL5EuqXFXwZSOln5HiI2pLRy4swxDl2odZNci8IgsZf +-----END CERTIFICATE----- diff --git a/crates/krishiv-session/tests/fixtures/tls/expired.key b/crates/krishiv-session/tests/fixtures/tls/expired.key new file mode 100644 index 000000000..4631f9d28 --- /dev/null +++ b/crates/krishiv-session/tests/fixtures/tls/expired.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg8ksF2UdsGprRC/5P +gdyMcBN8VwURdM3vUXa5HcwSDyyhRANCAAQvzj+joWLTAiJkiUblvD8vzamtliZx +aQzKpbxvOtEUW2Xa2Vp0EnFGbmBZUHaGAjQt8sG+902bhwILAodo8GoJ +-----END PRIVATE KEY----- diff --git a/crates/krishiv-session/tests/fixtures/tls/expired.pem b/crates/krishiv-session/tests/fixtures/tls/expired.pem new file mode 100644 index 000000000..485dbe3b6 --- /dev/null +++ b/crates/krishiv-session/tests/fixtures/tls/expired.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBpzCCAU2gAwIBAgIUCNU7KZLd0FhhpHtVqdYiCNfZjVswCgYIKoZIzj0EAwIw +GjEYMBYGA1UEAwwPa3Jpc2hpdiB0ZXN0IENBMB4XDTIwMDEwMTAwMDAwMFoXDTIw +MDEwMjAwMDAwMFowDjEMMAoGA1UEAwwDb2xkMFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAEL84/o6Fi0wIiZIlG5bw/L82prZYmcWkMyqW8bzrRFFtl2tladBJxRm5g +WVB2hgI0LfLBvvdNm4cCCwKHaPBqCaN9MHswJAYDVR0RBB0wG4YZc3BpZmZlOi8v +a3Jpc2hpdi50ZXN0L29sZDATBgNVHSUEDDAKBggrBgEFBQcDAjAdBgNVHQ4EFgQU +dKY7/Y41opdbgfN6aXcw0R/Ot78wHwYDVR0jBBgwFoAUrphYx3UgW7TjtwLsgOPk +xuBZS0owCgYIKoZIzj0EAwIDSAAwRQIhAJCAmibVk5A0XVv60Hq/RxLC1PZ75h8v +ktZC2Ec4r8yYAiB+/THLC9xb/ekSVZWX+dsu3lcbxxQspr4VZNYBtdnpMw== +-----END CERTIFICATE----- diff --git a/crates/krishiv-session/tests/fixtures/tls/nosan.der b/crates/krishiv-session/tests/fixtures/tls/nosan.der new file mode 100644 index 0000000000000000000000000000000000000000..7517dd13cd6e4dc2a2efb99fb875aef522bb3863 GIT binary patch literal 398 zcmXqLV(c<#V$@&2%*4pVB*JmkT65Cf3lWJ+szX;^Q{uROvp33si;Y98&EuRc3p0~} zl%a%y7#nja3o{RYc2Q<=MrN5pNosM4g0rK6f;g{{nW3Sfk&%IcrGZ(L1iz6XP{6>@ z5-MOIVkl%F0MX4^lv9JPmVo^^!7py6J}2a12-l`hM6wUyyY#9 zPS-i_-DrMm5yJvTCxuQ0ouZ1vku17({m+0(zDCDu{Z8UbJmY+uvE%Qth}<>>4dEkq Or)=;0t;nKd>IVRjcY{g* literal 0 HcmV?d00001 diff --git a/crates/krishiv-session/tests/fixtures/tls/nosan.key b/crates/krishiv-session/tests/fixtures/tls/nosan.key new file mode 100644 index 000000000..9bbd11e6b --- /dev/null +++ b/crates/krishiv-session/tests/fixtures/tls/nosan.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPMtd4G6Oeemj8yuN +G0M8tjBN+RT2xkkzrfs0obxzQa+hRANCAATuQORhvatufm20Pot6kKfxihCNjygH +01pE/zl0KNffQsN61aCpKNXuV3Zlz9OXolAtuCs9CI6B8TPh42lrFH/F +-----END PRIVATE KEY----- diff --git a/crates/krishiv-session/tests/fixtures/tls/nosan.pem b/crates/krishiv-session/tests/fixtures/tls/nosan.pem new file mode 100644 index 000000000..97f49e741 --- /dev/null +++ b/crates/krishiv-session/tests/fixtures/tls/nosan.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBijCCAS+gAwIBAgIUCNU7KZLd0FhhpHtVqdYiCNfZjVowCgYIKoZIzj0EAwIw +GjEYMBYGA1UEAwwPa3Jpc2hpdiB0ZXN0IENBMCAXDTI2MTExMjIwMDkwNloYDzIx +MjYxMDE5MjAwOTA2WjAUMRIwEAYDVQQDDAlyZXBvcnRpbmcwWTATBgcqhkjOPQIB +BggqhkjOPQMBBwNCAATuQORhvatufm20Pot6kKfxihCNjygH01pE/zl0KNffQsN6 +1aCpKNXuV3Zlz9OXolAtuCs9CI6B8TPh42lrFH/Fo1cwVTATBgNVHSUEDDAKBggr +BgEFBQcDAjAdBgNVHQ4EFgQUj2aAAZ22dhNQBrF/vZPYJOHB+HEwHwYDVR0jBBgw +FoAUrphYx3UgW7TjtwLsgOPkxuBZS0owCgYIKoZIzj0EAwIDSQAwRgIhAJlE5ksf +OMWXLM9LgTfaogCgAUIgiSAscnjDWQQtfo/mAiEA9VtfKvtiDmHMQ9sBiP3GWG2G +ICgTxN2Ut477IQQsNU4= +-----END CERTIFICATE----- diff --git a/crates/krishiv-session/tests/fixtures/tls/server.der b/crates/krishiv-session/tests/fixtures/tls/server.der new file mode 100644 index 0000000000000000000000000000000000000000..294b4c298ab1f4162eb9ffd8f88b000b13239e29 GIT binary patch literal 425 zcmXqLVq9v_#OS?%nTe5!NrdC7wdSO|7a|guREMs-ro?gmW^cFw7aNCGo5wj@7G@>` zDMJYZF*fE<7G@s)?4r!#jLb5HlGNf71!qSC1#w;@Gebi|BO?O?O9Qhg34S9(pn!p) zB~-vb#8AjU0HT{SCqFqcCnLYO#30f@n2jB50}~_EDrQD@W+w)g5BdMz6-K zeG#;3aj`+6ffUd}SwR+I1HmSQrR^;B3=E7wi-B^gEIc4TYO^u2va&NX8pwhK`B=nQ zMD_?=`>|5`lkS7^+GVRQ+J@LN3UnFBgQS&NfZ@;}vTjDi@lu88EswV|y=i#-WWtY literal 0 HcmV?d00001 diff --git a/crates/krishiv-session/tests/fixtures/tls/server.key b/crates/krishiv-session/tests/fixtures/tls/server.key new file mode 100644 index 000000000..90120d6a0 --- /dev/null +++ b/crates/krishiv-session/tests/fixtures/tls/server.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPZFprmJLRbv1hcFr +ROxj9sGD7TuNOzzf02sZAbyqZruhRANCAATwb/5/x2y6yvOV26MyNGriL7aTHjLy +vobkipYykwRkOpv26UwtgamXWNpbvxPBQysBOynIhMdVOIMvd+Ql6FKq +-----END PRIVATE KEY----- diff --git a/crates/krishiv-session/tests/fixtures/tls/server.pem b/crates/krishiv-session/tests/fixtures/tls/server.pem new file mode 100644 index 000000000..c6950cf32 --- /dev/null +++ b/crates/krishiv-session/tests/fixtures/tls/server.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBpTCCAUugAwIBAgIUCNU7KZLd0FhhpHtVqdYiCNfZjVcwCgYIKoZIzj0EAwIw +GjEYMBYGA1UEAwwPa3Jpc2hpdiB0ZXN0IENBMCAXDTI2MTExMjIwMDkwNloYDzIx +MjYxMDE5MjAwOTA2WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwWTATBgcqhkjOPQIB +BggqhkjOPQMBBwNCAATwb/5/x2y6yvOV26MyNGriL7aTHjLyvobkipYykwRkOpv2 +6UwtgamXWNpbvxPBQysBOynIhMdVOIMvd+Ql6FKqo3MwcTAaBgNVHREEEzARggls +b2NhbGhvc3SHBH8AAAEwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHQYDVR0OBBYEFLwQ +1vipG/It4Hd9pqrRPVQ9ARCKMB8GA1UdIwQYMBaAFK6YWMd1IFu047cC7IDj5Mbg +WUtKMAoGCCqGSM49BAMCA0gAMEUCIC87Y2T3RVeCUL5bHs++ERwaDFqBda5FDpki +ggC4758OAiEA64VPyRC98E9psZE/RLS69573erZ46xTYkUVXaxAyBFs= +-----END CERTIFICATE----- diff --git a/crates/krishiv-session/tests/fixtures/tls/server2.der b/crates/krishiv-session/tests/fixtures/tls/server2.der new file mode 100644 index 0000000000000000000000000000000000000000..99b020bf7c724eefde4cf10bf8cb17b9d979edb0 GIT binary patch literal 427 zcmXqLVq9*}#OS+#nTe5!NrdC7wdSO|7a|guREMs-ro?gmW^arE7aNCGo5wj@7G@>` zDMJYZF*fE<7G@s)?4r!#jLb5HlGNf71!qSC1#w;@Gebi|BO?PtV^fPL34S9(pn!p) zB~-vb%uvKY2%?)iCqFqcCnLYOMAyh5(mCE&eBHquktQ{pAdOO4CoIYUuBhY#0-9ORm zTh1A4UR+#kP-q|pv{F`(Mc6>Fi4$&ZJ4-zS10&F4pqwfT56G9=Y>cd|?97Y?vLHb| z7BLnPsZA@^heyo3%4VW|a@LW@^B6Qcj~d8>q?K8KA<-bRZbroMQibR(kGC_uX?XnP z*n>!KFXRwo_Fyn@Wl~66Go@$c4b~4Pr^-^Mm+&9?a$Ag3rgdf3PPM+hb~YbsnG_jT lGRru*I(({Y^g4aT_4}i(!k#zROuMUfWQEg%P}!(s`~VBlj^h9T literal 0 HcmV?d00001 diff --git a/crates/krishiv-session/tests/fixtures/tls/server2.key b/crates/krishiv-session/tests/fixtures/tls/server2.key new file mode 100644 index 000000000..e2ecf59ef --- /dev/null +++ b/crates/krishiv-session/tests/fixtures/tls/server2.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgzL+Wbk63f+qbUl9N +yByd4QoZFMYGsZyIGDBLJYTAnvahRANCAASOSAZzYOU4SqUWRMwm5gzkiulb4f5n +9BvW5WNjFuHp8eWZZ+QUS+ZqO0EZgI2JV5zLwDP4UUO53fxbr4TOMynR +-----END PRIVATE KEY----- diff --git a/crates/krishiv-session/tests/fixtures/tls/server2.pem b/crates/krishiv-session/tests/fixtures/tls/server2.pem new file mode 100644 index 000000000..6738374db --- /dev/null +++ b/crates/krishiv-session/tests/fixtures/tls/server2.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBpzCCAU2gAwIBAgIUCNU7KZLd0FhhpHtVqdYiCNfZjVwwCgYIKoZIzj0EAwIw +GjEYMBYGA1UEAwwPa3Jpc2hpdiB0ZXN0IENBMCAXDTI2MTExMjIwMTM1OFoYDzIx +MjYxMDE5MjAxMzU4WjAWMRQwEgYDVQQDDAtsb2NhbGhvc3QtMjBZMBMGByqGSM49 +AgEGCCqGSM49AwEHA0IABI5IBnNg5ThKpRZEzCbmDOSK6Vvh/mf0G9blY2MW4enx +5Zln5BRL5mo7QRmAjYlXnMvAM/hRQ7nd/FuvhM4zKdGjczBxMBoGA1UdEQQTMBGC +CWxvY2FsaG9zdIcEfwAAATATBgNVHSUEDDAKBggrBgEFBQcDATAdBgNVHQ4EFgQU +GrKor1dYmdUGNCfJmsTjngApicUwHwYDVR0jBBgwFoAUrphYx3UgW7TjtwLsgOPk +xuBZS0owCgYIKoZIzj0EAwIDSAAwRQIgZqyUjKnYBfA0ynZkl3QPxPTbFgkchalq +uSaOvT488H0CIQCpAxxCRUDyfoFKy9RF9+K1E0nZrJbdKsSoQuBVHVrGDw== +-----END CERTIFICATE----- diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index df51a4d22..313d0dea2 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -55,6 +55,13 @@ test = false doc = false bench = false +[[bin]] +name = "cert_names" +path = "fuzz_targets/cert_names.rs" +test = false +doc = false +bench = false + [[bin]] name = "capsule_import" path = "fuzz_targets/capsule_import.rs" diff --git a/fuzz/fuzz_targets/cert_names.rs b/fuzz/fuzz_targets/cert_names.rs new file mode 100644 index 000000000..c692d08d3 --- /dev/null +++ b/fuzz/fuzz_targets/cert_names.rs @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +//! The client-certificate name walk (P3-T002 PR-14c; design §3.4, threat model §4.9): +//! `CertNames::from_der` reads the subject common names and the DNS and URI subject alternative +//! names out of a DER certificate. The certificate was verified by rustls before it gets here, +//! but the walk must still return names or an error for any bytes, never panic, loop or +//! allocate without bound. Seeds: the certificates of `crates/krishiv-session/tests/fixtures/tls/`. +#![no_main] + +#[path = "../../crates/krishiv-session/src/auth/der.rs"] +mod der; + +libfuzzer_sys::fuzz_target!(|data: &[u8]| { + if let Ok(names) = der::CertNames::from_der(data) { + // Every name read lies inside the input. + let total: usize = names + .common_names + .iter() + .chain(&names.dns) + .chain(&names.uris) + .map(String::len) + .sum(); + assert!(total <= data.len()); + } +}); From 866685034146d33afe9e40d30411fbd11c8be51c Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:29:08 +0100 Subject: [PATCH 12/47] session: PR-14c review fixes (TLS settings refused without the tls feature, no client auth without a CA, format characters and constructed names refused, undecodable CN only refuses the cn rule, bounded certificate reads); CI runs the tls handshake tests Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- .github/workflows/ci.yml | 2 + crates/krishiv-session/public-api.txt | 5 +- crates/krishiv-session/src/auth/der.rs | 46 ++++- crates/krishiv-session/src/auth/mtls.rs | 3 + crates/krishiv-session/src/auth/mtls_tests.rs | 183 ++++++++++++++++-- crates/krishiv-session/src/auth/secret_ref.rs | 36 ++++ crates/krishiv-session/src/auth/tls.rs | 29 +-- crates/krishiv-session/src/auth/tls_config.rs | 14 +- 8 files changed, 280 insertions(+), 38 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 004351449..905cd1bc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -290,6 +290,8 @@ jobs: KRISHIV_SIM_TPCH_DIR: ${{ runner.temp }}/tpch-sf0.1 - run: cargo test --workspace --doc --locked - run: cargo nextest run -p krishiv-session --features telemetry-otlp --test otlp_collector --locked + # Server TLS (feature `tls`, off by default): the in-memory rustls handshakes. + - run: cargo nextest run -p krishiv-session --features tls --lib -E 'test(/auth::mtls_tests::tls/)' --locked - name: Disk left if: always() run: df -h / "$RUNNER_TEMP" diff --git a/crates/krishiv-session/public-api.txt b/crates/krishiv-session/public-api.txt index 086beb856..87bfc9862 100644 --- a/crates/krishiv-session/public-api.txt +++ b/crates/krishiv-session/public-api.txt @@ -317,6 +317,7 @@ impl core::marker::StructuralPartialEq for krishiv_session::auth::Authenticated #[non_exhaustive] pub struct krishiv_session::auth::CertNames pub krishiv_session::auth::CertNames::common_names: alloc::vec::Vec pub krishiv_session::auth::CertNames::dns: alloc::vec::Vec +pub krishiv_session::auth::CertNames::undecodable_common_names: usize pub krishiv_session::auth::CertNames::uris: alloc::vec::Vec impl krishiv_session::auth::CertNames pub fn krishiv_session::auth::CertNames::from_der(&[u8]) -> core::result::Result @@ -365,7 +366,7 @@ pub fn krishiv_session::auth::FailureThrottle::new(krishiv_session::auth::Thrott pub fn krishiv_session::auth::FailureThrottle::record_failure(&self, core::net::ip_addr::IpAddr, std::time::Instant) -> bool impl core::fmt::Debug for krishiv_session::auth::FailureThrottle pub fn krishiv_session::auth::FailureThrottle::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result -pub struct krishiv_session::auth::MtlsConfig +#[non_exhaustive] pub struct krishiv_session::auth::MtlsConfig pub krishiv_session::auth::MtlsConfig::client_ca: core::option::Option pub krishiv_session::auth::MtlsConfig::client_certificates: krishiv_session::auth::ClientCertificates pub krishiv_session::auth::MtlsConfig::principal_from: krishiv_session::auth::PrincipalFrom @@ -451,7 +452,7 @@ impl core::fmt::Debug for krishiv_session::auth::ThrottleConfig pub fn krishiv_session::auth::ThrottleConfig::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::Copy for krishiv_session::auth::ThrottleConfig impl core::marker::StructuralPartialEq for krishiv_session::auth::ThrottleConfig -pub struct krishiv_session::auth::TlsConfig +#[non_exhaustive] pub struct krishiv_session::auth::TlsConfig pub krishiv_session::auth::TlsConfig::cert_file: core::option::Option pub krishiv_session::auth::TlsConfig::key_file: core::option::Option pub krishiv_session::auth::TlsConfig::tls13_only: bool diff --git a/crates/krishiv-session/src/auth/der.rs b/crates/krishiv-session/src/auth/der.rs index 5bb303adc..e6a1958c6 100644 --- a/crates/krishiv-session/src/auth/der.rs +++ b/crates/krishiv-session/src/auth/der.rs @@ -49,6 +49,10 @@ pub struct CertNames { pub dns: Vec, /// Every `uniformResourceIdentifier` of the `subjectAltName` extension, in order. pub uris: Vec, + /// Subject `commonName` attributes that are not valid text (a `BMPString`, a control + /// character...). Counted, not refused: they matter only to the `cn` rule, which refuses a + /// certificate with any. + pub undecodable_common_names: usize, } // Universal tags. @@ -68,6 +72,10 @@ const SUBJECT_UID: u8 = 0x82; const EXTENSIONS: u8 = 0xa3; const DNS_NAME: u8 = 0x82; const URI: u8 = 0x86; +/// The constructed forms of the same two tags: not valid for `IA5String` names, refused rather +/// than skipped, so no second name hides from the exactly-one rule. +const DNS_NAME_CONSTRUCTED: u8 = 0xa2; +const URI_CONSTRUCTED: u8 = 0xa6; /// `id-at-commonName`, 2.5.4.3. const OID_COMMON_NAME: &[u8] = &[0x55, 0x04, 0x03]; @@ -179,10 +187,8 @@ impl CertNames { let extensions = tbs.optional(EXTENSIONS)?; tbs.finish()?; - let mut names = Self { - common_names: common_names(subject)?, - ..Self::default() - }; + let mut names = Self::default(); + common_names(subject, &mut names)?; if let Some(extensions) = extensions { let mut explicit = Reader::new(extensions); let list = explicit.expect(SEQUENCE)?; @@ -210,8 +216,7 @@ impl CertNames { /// The `commonName` values of the `Name` whose contents are `subject` /// (`RDNSequence`: a sequence of sets of attribute type and value). -fn common_names(subject: &[u8]) -> Result, DerError> { - let mut out = Vec::new(); +fn common_names(subject: &[u8], names: &mut CertNames) -> Result<(), DerError> { let mut rdns = Reader::new(subject); while !rdns.is_empty() { let mut set = Reader::new(rdns.expect(SET)?); @@ -221,11 +226,14 @@ fn common_names(subject: &[u8]) -> Result, DerError> { let (tag, value) = attr.any()?; attr.finish()?; if id == OID_COMMON_NAME { - out.push(text(tag, value)?); + match text(tag, value) { + Ok(cn) => names.common_names.push(cn), + Err(_) => names.undecodable_common_names += 1, + } } } } - Ok(out) + Ok(()) } /// The DNS and URI names of the `GeneralNames` whose DER is `value`; other kinds are skipped. @@ -238,6 +246,7 @@ fn alt_names(value: &[u8], names: &mut CertNames) -> Result<(), DerError> { match list.any()? { (DNS_NAME, v) => names.dns.push(text(IA5_STRING, v)?), (URI, v) => names.uris.push(text(IA5_STRING, v)?), + (DNS_NAME_CONSTRUCTED | URI_CONSTRUCTED, _) => return Err(DerError::Unexpected), _ => {} } } @@ -255,8 +264,27 @@ fn text(tag: u8, bytes: &[u8]) -> Result { } _ => return Err(DerError::BadText), }; - if s.chars().any(char::is_control) { + if s.chars().any(|c| c.is_control() || is_format(c)) { return Err(DerError::BadText); } Ok(s.to_owned()) } + +/// Invisible format characters that make two names look alike or reorder text in logs and +/// tables (the Unicode `Cf` code points a name could plausibly carry): soft hyphen, zero-width +/// and joiner characters, bidirectional marks, embeddings, overrides and isolates, invisible +/// operators and the byte order mark. +fn is_format(c: char) -> bool { + matches!( + c, + '\u{00ad}' + | '\u{061c}' + | '\u{180e}' + | '\u{200b}'..='\u{200f}' + | '\u{202a}'..='\u{202e}' + | '\u{2060}'..='\u{2064}' + | '\u{2066}'..='\u{206f}' + | '\u{feff}' + | '\u{fff9}'..='\u{fffb}' + ) +} diff --git a/crates/krishiv-session/src/auth/mtls.rs b/crates/krishiv-session/src/auth/mtls.rs index 0144a4656..28fa9f0d2 100644 --- a/crates/krishiv-session/src/auth/mtls.rs +++ b/crates/krishiv-session/src/auth/mtls.rs @@ -86,6 +86,7 @@ impl PrincipalFrom { /// The `krishiv.auth.clientCertificates`, `krishiv.auth.clientCa` and `krishiv.auth.mtls.*` /// settings. #[derive(Debug, Clone, Default, PartialEq, Eq)] +#[non_exhaustive] pub struct MtlsConfig { /// Whether a client certificate is asked for. pub client_certificates: ClientCertificates, @@ -157,6 +158,8 @@ impl MtlsConfig { let values = match self.principal_from { PrincipalFrom::SanUri => names.uris, PrincipalFrom::SanDns => names.dns, + // A common name that is not valid text could be the second one: refuse. + PrincipalFrom::Cn if names.undecodable_common_names > 0 => return None, PrincipalFrom::Cn => names.common_names, }; let [name] = <[String; 1]>::try_from(values).ok()?; diff --git a/crates/krishiv-session/src/auth/mtls_tests.rs b/crates/krishiv-session/src/auth/mtls_tests.rs index 382b60f8c..f26f9da15 100644 --- a/crates/krishiv-session/src/auth/mtls_tests.rs +++ b/crates/krishiv-session/src/auth/mtls_tests.rs @@ -105,6 +105,136 @@ fn der_refuses_a_non_minimal_length() { ); } +/// One DER element. +fn tlv(tag: u8, contents: &[u8]) -> Vec { + let mut out = vec![tag]; + let n = contents.len(); + if n < 0x80 { + out.push(u8::try_from(n).unwrap()); + } else { + let bytes: Vec = n + .to_be_bytes() + .into_iter() + .skip_while(|b| *b == 0) + .collect(); + out.push(0x80 | u8::try_from(bytes.len()).unwrap()); + out.extend(bytes); + } + out.extend_from_slice(contents); + out +} + +/// A `commonName` attribute with value tag `tag`. +fn cn_attr(tag: u8, value: &[u8]) -> Vec { + tlv( + 0x30, + &[tlv(0x06, &[0x55, 0x04, 0x03]), tlv(tag, value)].concat(), + ) +} + +/// A certificate shell (unsigned) with `subject` (the contents of the `Name`) and, if given, a +/// subjectAltName extension holding `alt` (the contents of `GeneralNames`). +fn shell(subject: &[u8], alt: Option<&[u8]>) -> Vec { + let mut tbs = [ + tlv(0xa0, &tlv(0x02, &[2])), + tlv(0x02, &[1]), + tlv(0x30, &[]), + tlv(0x30, &[]), + tlv(0x30, &[]), + tlv(0x30, subject), + tlv(0x30, &[]), + ] + .concat(); + if let Some(alt) = alt { + let ext = tlv( + 0x30, + &[tlv(0x06, &[0x55, 0x1d, 0x11]), tlv(0x04, &tlv(0x30, alt))].concat(), + ); + tbs.extend(tlv(0xa3, &tlv(0x30, &ext))); + } + tlv( + 0x30, + &[tlv(0x30, &tbs), tlv(0x30, &[]), tlv(0x03, &[0])].concat(), + ) +} + +#[test] +fn der_reads_a_multi_valued_rdn() { + // One SET holding two common names: both are read, so the `cn` rule refuses. + let rdn = tlv(0x31, &[cn_attr(0x0c, b"a"), cn_attr(0x13, b"b")].concat()); + let cert = shell(&rdn, None); + assert_eq!(CertNames::from_der(&cert).unwrap().common_names, ["a", "b"]); + let c = mtls("required", "cn"); + assert!(c.mtls.principal_of(&cert).is_none()); +} + +#[test] +fn der_refuses_constructed_and_invisible_names() { + let subject = tlv(0x31, &cn_attr(0x0c, b"svc")); + // A constructed [6] next to a primitive URI would hide a second name. + let alt = [ + tlv(0x86, b"spiffe://x/a"), + tlv(0xa6, &tlv(0x16, b"spiffe://x/b")), + ] + .concat(); + assert_eq!( + CertNames::from_der(&shell(&subject, Some(&alt))), + Err(DerError::Unexpected) + ); + let alt = tlv(0xa2, &tlv(0x16, b"b.test")); + assert_eq!( + CertNames::from_der(&shell(&subject, Some(&alt))), + Err(DerError::Unexpected) + ); + // A right-to-left override in a common name (UTF8String). + let bidi = tlv(0x31, &cn_attr(0x0c, "svc\u{202e}cba".as_bytes())); + let names = CertNames::from_der(&shell(&bidi, None)).unwrap(); + assert_eq!(names.undecodable_common_names, 1); + // Other kinds (an IP address, a directory name) are skipped. + let alt = [ + tlv(0x87, &[127, 0, 0, 1]), + tlv(0xa4, &tlv(0x30, &[])), + tlv(0x86, b"u"), + ] + .concat(); + assert_eq!( + CertNames::from_der(&shell(&subject, Some(&alt))) + .unwrap() + .uris, + ["u"] + ); +} + +#[test] +fn an_undecodable_common_name_matters_only_to_the_cn_rule() { + // A BMPString common name next to one URI SAN. + let subject = tlv(0x31, &cn_attr(0x1e, &[0, b'x'])); + let cert = shell(&subject, Some(&tlv(0x86, b"spiffe://x/svc"))); + let names = CertNames::from_der(&cert).unwrap(); + assert_eq!(names.undecodable_common_names, 1); + assert_eq!( + mtls("required", "san-uri") + .mtls + .principal_of(&cert) + .unwrap() + .0, + "spiffe://x/svc" + ); + assert!(mtls("required", "cn").mtls.principal_of(&cert).is_none()); + // With a decodable common name as well, `cn` still refuses: the other could be the second. + let subject = [ + tlv(0x31, &cn_attr(0x0c, b"svc")), + tlv(0x31, &cn_attr(0x1e, &[0, b'x'])), + ] + .concat(); + assert!( + mtls("required", "cn") + .mtls + .principal_of(&shell(&subject, None)) + .is_none() + ); +} + #[test] fn mtls_principal_rule() { // ADR 0046 §6: exactly one value of the configured kind, no fallback to another kind. @@ -317,14 +447,12 @@ fn startup_incoherent_configs() { ]) .unwrap(); let none = config(&[]).unwrap(); - let tls = TlsConfig::from_settings([ - ( - "krishiv.server.tls.certFile", - fixture("server.pem").as_str(), - ), - ("krishiv.server.tls.keyFile", fixture("server.key").as_str()), - ]) - .unwrap(); + // Built directly: a build without the `tls` feature refuses these settings. + let tls = TlsConfig { + cert_file: Some(fixture("server.pem").into()), + key_file: Some(fixture("server.key").into()), + tls13_only: false, + }; let plain = TlsConfig::default(); let public: SocketAddr = "0.0.0.0:15002".parse().unwrap(); let local: SocketAddr = "127.0.0.1:15002".parse().unwrap(); @@ -352,14 +480,34 @@ fn startup_incoherent_configs() { #[test] fn tls_settings_are_checked() { - let e = TlsConfig::from_settings([("krishiv.server.tls.certFile", "/x.pem")]).unwrap_err(); - assert!(e.to_string().contains("keyFile"), "{e}"); - let e = TlsConfig::from_settings([("krishiv.server.tls.keyFile", "/x.key")]).unwrap_err(); - assert!(e.to_string().contains("certFile"), "{e}"); + #[cfg(feature = "tls")] + { + let e = TlsConfig::from_settings([("krishiv.server.tls.certFile", "/x.pem")]).unwrap_err(); + assert!(e.to_string().contains("keyFile"), "{e}"); + let e = TlsConfig::from_settings([("krishiv.server.tls.keyFile", "/x.key")]).unwrap_err(); + assert!(e.to_string().contains("certFile"), "{e}"); + } let e = TlsConfig::from_settings([("krishiv.server.tls.minVersion", "1.1")]).unwrap_err(); assert!(invalid_config(&e), "{e}"); let e = TlsConfig::from_settings([("krishiv.server.tls.ciphers", "x")]).unwrap_err(); assert!(invalid_config(&e), "{e}"); + let only = TlsConfig::from_settings([("krishiv.server.tls.minVersion", "1.3")]).unwrap(); + assert!(!only.is_on() && only.tls13_only); +} + +#[cfg(not(feature = "tls"))] +#[test] +fn tls_settings_need_the_tls_feature() { + // S1: a build that cannot serve TLS refuses a configuration asking for it. + for key in ["krishiv.server.tls.certFile", "krishiv.server.tls.keyFile"] { + let e = TlsConfig::from_settings([(key, "/a.pem")]).unwrap_err(); + assert!(e.to_string().contains("no TLS"), "{e}"); + } +} + +#[cfg(feature = "tls")] +#[test] +fn tls_settings_with_files() { let c = TlsConfig::from_settings([ ("krishiv.server.tls.certFile", "file:/a.pem"), ("krishiv.server.tls.keyFile", "/a.key"), @@ -477,7 +625,7 @@ mod tls { } #[test] - fn tls_serves_and_reloads_the_certificate() { + fn tls_reload() { let dir = tempfile::tempdir().unwrap(); let (cert, key) = server_files(dir.path(), "server"); let server = ServerTls::new(&tls_config(&cert, &key), &MtlsConfig::default()) @@ -555,6 +703,15 @@ mod tls { std::fs::copy(fixture("server2.key"), &key).unwrap(); let e = ServerTls::new(&tls_config(&cert, &key), &MtlsConfig::default()).unwrap_err(); assert!(e.to_string().contains("does not match"), "{e}"); + // Client certificates asked for with no CA: never served without the verifier. + server_files(dir.path(), "server"); + let no_ca = MtlsConfig { + client_certificates: super::super::ClientCertificates::Required, + ..MtlsConfig::default() + }; + let e = ServerTls::new(&tls_config(&cert, &key), &no_ca).unwrap_err(); + assert!(e.to_string().contains("clientCa"), "{e}"); + std::fs::copy(fixture("server2.key"), &key).unwrap(); // TLS off. assert!( ServerTls::new(&TlsConfig::default(), &MtlsConfig::default()) diff --git a/crates/krishiv-session/src/auth/secret_ref.rs b/crates/krishiv-session/src/auth/secret_ref.rs index 836fb3651..75dea2edc 100644 --- a/crates/krishiv-session/src/auth/secret_ref.rs +++ b/crates/krishiv-session/src/auth/secret_ref.rs @@ -127,6 +127,42 @@ pub(crate) fn read_private_file( Ok(SecretString::new(text)) } +/// The contents of the public file `path` (a certificate chain or CA file), at most `most` +/// bytes: a regular file (checked before it is opened, so a FIFO cannot block startup). +#[cfg(feature = "tls")] +pub(crate) fn read_public_file( + key: &str, + path: &std::path::Path, + most: usize, +) -> Result, KrishivError> { + use std::io::Read as _; + let shown = format!("file:{}", path.display()); + let unreadable = + |e: std::io::Error| invalid(key, &shown, &format!("cannot be read: {}", e.kind())); + let regular = |m: &std::fs::Metadata| { + if m.is_file() { + Ok(()) + } else { + Err(invalid(key, &shown, "not a regular file")) + } + }; + regular(&std::fs::metadata(path).map_err(unreadable)?)?; + let file = std::fs::File::open(path).map_err(unreadable)?; + regular(&file.metadata().map_err(unreadable)?)?; + let mut bytes = Vec::new(); + file.take(most as u64 + 1) + .read_to_end(&mut bytes) + .map_err(unreadable)?; + if bytes.len() > most { + return Err(invalid( + key, + &shown, + &format!("the file is longer than {most} bytes"), + )); + } + Ok(bytes) +} + /// Refuses a secret file that group or others may read. #[cfg(unix)] fn check_private(key: &str, shown: &str, meta: &std::fs::Metadata) -> Result<(), KrishivError> { diff --git a/crates/krishiv-session/src/auth/tls.rs b/crates/krishiv-session/src/auth/tls.rs index b36c85b5c..f953551e3 100644 --- a/crates/krishiv-session/src/auth/tls.rs +++ b/crates/krishiv-session/src/auth/tls.rs @@ -22,9 +22,12 @@ use rustls::sign::CertifiedKey; use rustls::{RootCertStore, ServerConfig}; use super::mtls::{ClientCertificates, MtlsConfig}; -use super::secret_ref::{invalid, read_private_file}; +use super::secret_ref::{invalid, read_private_file, read_public_file}; use super::tls_config::{CERT_KEY, KEY_KEY, MIN_VERSION_KEY, TlsConfig}; +/// The longest certificate chain or CA file read. +const MAX_CERT_FILE_BYTES: usize = 1024 * 1024; + /// The longest private key file read (a PEM RSA-8192 key is about 6.3 KiB). const MAX_KEY_FILE_BYTES: usize = 64 * 1024; @@ -80,7 +83,15 @@ impl ServerTls { .with_protocol_versions(versions) .map_err(|e| invalid(MIN_VERSION_KEY, "", &e.to_string()))?; let builder = match (mtls.client_certificates, &mtls.client_ca) { - (ClientCertificates::Off, _) | (_, None) => builder.with_no_client_auth(), + (ClientCertificates::Off, _) => builder.with_no_client_auth(), + (_, None) => { + // `MtlsConfig::validate` refuses this; never serve without the verifier asked for. + return Err(invalid( + "krishiv.auth.clientCa", + "", + "required when krishiv.auth.clientCertificates is optional or required", + )); + } (mode, Some(ca)) => { let roots = Arc::new(client_roots(ca)?); let verifier = @@ -116,7 +127,8 @@ impl ServerTls { } /// Re-reads the certificate and key files. New handshakes get the new material; on an - /// error the old material keeps serving. + /// error the old material keeps serving. Blocking file I/O: an async caller (the `SIGHUP` + /// handler) runs it on a blocking thread. /// /// # Errors /// As [`ServerTls::new`] for the certificate and key. @@ -142,13 +154,7 @@ fn load_key( key_file: &Path, provider: &CryptoProvider, ) -> Result { - let pem = std::fs::read(cert_file).map_err(|e| { - invalid( - CERT_KEY, - &shown(cert_file), - &format!("cannot be read: {}", e.kind()), - ) - })?; + let pem = read_public_file(CERT_KEY, cert_file, MAX_CERT_FILE_BYTES)?; let chain = CertificateDer::pem_slice_iter(&pem) .collect::, _>>() .map_err(|_| invalid(CERT_KEY, &shown(cert_file), "not a PEM certificate chain"))?; @@ -170,8 +176,7 @@ fn load_key( /// The client CA certificates. fn client_roots(ca: &Path) -> Result { let key = "krishiv.auth.clientCa"; - let pem = std::fs::read(ca) - .map_err(|e| invalid(key, &shown(ca), &format!("cannot be read: {}", e.kind())))?; + let pem = read_public_file(key, ca, MAX_CERT_FILE_BYTES)?; let mut roots = RootCertStore::empty(); for cert in CertificateDer::pem_slice_iter(&pem) { let cert = cert.map_err(|_| invalid(key, &shown(ca), "not a PEM certificate file"))?; diff --git a/crates/krishiv-session/src/auth/tls_config.rs b/crates/krishiv-session/src/auth/tls_config.rs index dd01ab15d..5f17b5e3c 100644 --- a/crates/krishiv-session/src/auth/tls_config.rs +++ b/crates/krishiv-session/src/auth/tls_config.rs @@ -1,7 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 //! The `krishiv.server.tls.*` settings (design §3.5) and the startup check that a listener's //! address, authentication and TLS make sense together (threat model §6 item 9). Read without -//! the `tls` feature, so a build without TLS still refuses a TLS configuration it cannot serve. +//! the `tls` feature too: a build without it refuses `certFile`/`keyFile`, so a configuration +//! asking for TLS never starts a server that would serve tokens in clear. use std::net::SocketAddr; use std::path::PathBuf; @@ -17,6 +18,7 @@ pub(crate) const MIN_VERSION_KEY: &str = "krishiv.server.tls.minVersion"; /// The `krishiv.server.tls.*` settings. #[derive(Debug, Clone, Default, PartialEq, Eq)] +#[non_exhaustive] pub struct TlsConfig { /// The PEM certificate chain, leaf first. pub cert_file: Option, @@ -31,13 +33,21 @@ impl TlsConfig { /// /// # Errors /// `KRISHIV_INVALID_CONFIG_VALUE` for an unknown `krishiv.server.tls.*` key, a bad - /// `minVersion`, or a certificate without a key (or a key without a certificate). + /// `minVersion`, a certificate without a key (or a key without a certificate), or a + /// certificate or key in a build without the `tls` feature. pub fn from_settings<'a>( settings: impl IntoIterator, ) -> Result { let mut out = Self::default(); for (key, raw) in settings { match key { + CERT_KEY | KEY_KEY if !cfg!(feature = "tls") => { + return Err(invalid( + key, + raw, + "this build has no TLS (the `tls` feature of krishiv-session is off)", + )); + } CERT_KEY => out.cert_file = Some(path(key, raw)?), KEY_KEY => out.key_file = Some(path(key, raw)?), MIN_VERSION_KEY => { From d1e7323aa94753882ecf993ffe04ca2afad09335 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:43:47 +0100 Subject: [PATCH 13/47] =?UTF-8?q?sql:=20statement=5Ftargets=20expects=20Br?= =?UTF-8?q?owse=20for=20SHOW=20TABLES=20(metadata=20at=20Browse,=20ADR=200?= =?UTF-8?q?046=20=C2=A73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-sql/tests/statement_targets.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/krishiv-sql/tests/statement_targets.rs b/crates/krishiv-sql/tests/statement_targets.rs index 917ca8b25..66173ef0c 100644 --- a/crates/krishiv-sql/tests/statement_targets.rs +++ b/crates/krishiv-sql/tests/statement_targets.rs @@ -86,8 +86,8 @@ fn each_statement_shape_needs_its_privileges() { "Modify Path(/n)", ], ), - ("SHOW TABLES", &["Select CurrentNamespace"]), - ("SHOW TABLES IN db", &["Select Namespace(db)"]), + ("SHOW TABLES", &["Browse CurrentNamespace"]), + ("SHOW TABLES IN db", &["Browse Namespace(db)"]), ("INSERT INTO t VALUES (1)", &["Modify Table(t)"]), ( "CREATE FUNCTION f(x INT) RETURNS INT RETURN x + 1", From 2e60237c0dad23938616b994c0383c0b37a34016 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Fri, 13 Nov 2026 07:52:49 +0000 Subject: [PATCH 14/47] session: the TLS startup test resets the copied key's mode, so it does not depend on the checkout's umask Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-session/src/auth/mtls_tests.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/krishiv-session/src/auth/mtls_tests.rs b/crates/krishiv-session/src/auth/mtls_tests.rs index f26f9da15..a7ce19102 100644 --- a/crates/krishiv-session/src/auth/mtls_tests.rs +++ b/crates/krishiv-session/src/auth/mtls_tests.rs @@ -701,6 +701,12 @@ mod tls { } // A key that does not belong to the certificate. std::fs::copy(fixture("server2.key"), &key).unwrap(); + // `copy` carries the fixture's mode over (0664 under umask 002): owner-only again. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o600)).unwrap(); + } let e = ServerTls::new(&tls_config(&cert, &key), &MtlsConfig::default()).unwrap_err(); assert!(e.to_string().contains("does not match"), "{e}"); // Client certificates asked for with no CA: never served without the verifier. From c845cde820b51a2898f72ae0838ae7695822c260 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Fri, 13 Nov 2026 07:57:50 +0000 Subject: [PATCH 15/47] session: OIDC access tokens (P3-T002 PR-14d): JWT verifier on aws-lc-rs, JWK set cache and fetch thread, OIDC role lists, jwt_parse fuzz target - jose.rs: strict JWS/JWK set/discovery parsers (16 KiB tokens, canonical base64url, duplicate members refused, RS256/384/512 and ES256/384 only, crit refused, header key members never used), claim checks. - oidc.rs (feature oidc): signatures on aws-lc-rs with no none or HMAC path; JwksCache (one on-demand fetch a minute, the first request waits, a token without kid needs exactly one fitting key, failed or empty fetches keep the keys); HttpKeySource (no redirects, 1 MiB cap, one 10 s deadline per fetch). - oidc_config.rs: krishiv.auth.oidc.* including roles.{viewer,operator,admin} for role-claim values, separate from the certificate names of krishiv.auth.roles.*; https locations with a host, no userinfo. - server.rs: JWS-shaped bearers go to OIDC; a JWT beside a client certificate fails as unavailable or expired rather than as a mismatch; only invalid tokens count toward the throttle. Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- .github/workflows/ci.yml | 5 +- .github/workflows/fuzz.yml | 2 + Cargo.lock | 2 + Cargo.toml | 2 + crates/krishiv-session/CLAUDE.md | 2 +- crates/krishiv-session/Cargo.toml | 8 +- crates/krishiv-session/src/auth/config.rs | 31 +- crates/krishiv-session/src/auth/der.rs | 2 +- crates/krishiv-session/src/auth/jose.rs | 610 ++++++++++++++++ crates/krishiv-session/src/auth/jose_tests.rs | 487 +++++++++++++ crates/krishiv-session/src/auth/mod.rs | 27 +- crates/krishiv-session/src/auth/oidc.rs | 493 +++++++++++++ .../krishiv-session/src/auth/oidc_config.rs | 238 +++++++ .../src/auth/oidc_http_tests.rs | 292 ++++++++ crates/krishiv-session/src/auth/oidc_tests.rs | 661 ++++++++++++++++++ crates/krishiv-session/src/auth/server.rs | 257 ++++++- crates/krishiv-session/src/auth/tests.rs | 2 +- crates/krishiv-session/src/auth/tls_config.rs | 2 +- fuzz/Cargo.toml | 16 +- fuzz/fuzz_targets/jwt_parse.rs | 46 ++ 20 files changed, 3138 insertions(+), 47 deletions(-) create mode 100644 crates/krishiv-session/src/auth/jose.rs create mode 100644 crates/krishiv-session/src/auth/jose_tests.rs create mode 100644 crates/krishiv-session/src/auth/oidc.rs create mode 100644 crates/krishiv-session/src/auth/oidc_config.rs create mode 100644 crates/krishiv-session/src/auth/oidc_http_tests.rs create mode 100644 crates/krishiv-session/src/auth/oidc_tests.rs create mode 100644 fuzz/fuzz_targets/jwt_parse.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 905cd1bc1..b550ec033 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -290,8 +290,9 @@ jobs: KRISHIV_SIM_TPCH_DIR: ${{ runner.temp }}/tpch-sf0.1 - run: cargo test --workspace --doc --locked - run: cargo nextest run -p krishiv-session --features telemetry-otlp --test otlp_collector --locked - # Server TLS (feature `tls`, off by default): the in-memory rustls handshakes. - - run: cargo nextest run -p krishiv-session --features tls --lib -E 'test(/auth::mtls_tests::tls/)' --locked + # Server TLS (`tls`) and OIDC (`oidc`), both off by default: the in-memory rustls handshakes, + # and the JWT verifier against keys generated per run. + - run: cargo nextest run -p krishiv-session --features tls,oidc --lib -E 'test(/auth::mtls_tests::tls/) | test(/auth::oidc_tests/)' --locked - name: Disk left if: always() run: df -h / "$RUNNER_TEMP" diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 3c2e71852..47c2e2d75 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -47,6 +47,8 @@ jobs: args: -max_len=256 - target: cert_names args: -max_len=8192 + - target: jwt_parse + args: -max_len=20000 env: RUSTC_WRAPPER: "" # instrumented builds are not cacheable TARGET: ${{ matrix.target }} diff --git a/Cargo.lock b/Cargo.lock index 78b9a3688..e4c1ba687 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2451,6 +2451,7 @@ dependencies = [ "arrow-buffer", "arrow-schema", "arrow-select", + "aws-lc-rs", "bytes", "futures", "http", @@ -2477,6 +2478,7 @@ dependencies = [ "prost", "reqwest", "rustls", + "serde", "serde_json", "sha2", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index ea8fdbddc..ee83b722c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -161,6 +161,8 @@ rust-embed = { version = "8.13", default-features = false } utoipa = { version = "6.0", default-features = false } rmcp = { version = "3.5.1", default-features = false } rustls = { version = "0.23", default-features = false } +# OIDC JWT signatures (krishiv-session `oidc`); already in the tree as rustls's crypto provider. +aws-lc-rs = { version = "1.18.2", default-features = false, features = ["aws-lc-sys"] } tracing = { version = "0.1.44", default-features = false } tracing-subscriber = { version = "0.3.23", default-features = false } opentelemetry = { version = "0.33.1", default-features = false } diff --git a/crates/krishiv-session/CLAUDE.md b/crates/krishiv-session/CLAUDE.md index cf7bb6abe..24da67fae 100644 --- a/crates/krishiv-session/CLAUDE.md +++ b/crates/krishiv-session/CLAUDE.md @@ -24,7 +24,7 @@ Paths are relative to this crate. - `src/query_fold.rs` the bookkeeping around one running query: `QueryStart` (counters read at start), `QueryFold`/`MetricStream`/`PendingFold` (the one fold of its metrics into the engine series and `system.queries` when the stream ends, fails or is dropped; holds the admission permit), `QueuedRow` (the history row of a query waiting for admission) - `src/admission.rs` the admission seam (E5): `AdmissionController::admit(&QueryRequest) -> Permit` asked once per query in `run_on_query_logged`; default `Unlimited`; a waiting `admit` shows the query `queued` in `system.queries`, a refusal closes the row `failed`, a drop while queued `cancelled`; the permit (slot, optional per-query memory budget) lives in the query's `QueryFold` and releases on drop; `tests/admission.rs` - `src/access.rs` `StatementInfo` / `Session::statement_info` (class + read securables with columns), write/DDL checks (`authorize_statement`), the `krishiv::audit` denial record, `PathPrefixPolicy` -- `src/auth/` (P3-T002, design `design/P3-T002-authentication.md`, ADR 0046) server authentication: `Role` (viewer < operator < admin), `Credentials` / `Authenticated` / `AuthFailure` (Spark's two `UNAUTHENTICATED` messages), the `Authenticator` trait and `ServerAuth` (throttle, then static tokens; one `krishiv::auth` warn record and one `(surface, method)` counter per failure, never the credential); `config.rs` `AuthConfig::from_settings` (`krishiv.auth.tokens..{secret,principal,kind,role}`, `spark.connect.authenticate.token` = one admin token named `spark`, `krishiv.auth.failureThrottle.*`; unknown `krishiv.auth.*` keys refused); `secret_ref.rs` `env:`/`file:` references (a file readable by group or others refuses startup); `tokens.rs` SHA-256 digests compared in constant time against every entry (secrets >= 32 bytes, no two tokens share one); `throttle.rs` per-address failure blocks, bounded at 65,536 addresses; `mtls.rs` client certificates (`krishiv.auth.clientCertificates` off/optional/required, `clientCa`, `mtls.principalFrom` san-uri/san-dns/cn: exactly one value of that kind or refused, `mtls.userPrincipals`; roles from `krishiv.auth.roles.{viewer,operator,admin}` lists via `config.rs` `RoleLists`; a bearer token next to a certificate must name the same principal; `required` never falls back to a token); `der.rs` `CertNames::from_der`, the bounded no-recursion DER walk of the leaf's subject CN and DNS/URI SANs (fuzz target `fuzz/fuzz_targets/cert_names.rs` includes it by path); `tls_config.rs` `TlsConfig` (`krishiv.server.tls.{certFile,keyFile,minVersion}`) and `check_listener` (startup refusals: non-loopback without auth, non-loopback tokens without TLS, client certificates without TLS); `tls.rs` (feature `tls`) `ServerTls`, the rustls `ServerConfig` on aws-lc-rs with h2/http1.1 ALPN, WebPKI client verifier, a key file checked like a secret file and `reload()` swapping the certified key (a failed reload keeps the old one); `mtls_tests.rs` with in-memory handshakes against `tests/fixtures/tls/`; `roles.rs` `RolePolicy`, the ADR 0046 §3 matrix as an `AccessPolicy` (viewer: `Browse`, own `system.*` rows, `/metrics`; operator: `Select`, `Usage`, `Monitor`, `Operate`; admin: `Modify`/`Create`/`Drop`/`Manage`; client code at `krishiv.auth.udf.minRole`; `Impersonate` never; no role denies all), `tests/roles_matrix.rs`. Metadata statements need `Browse` (`authorize` falls back to `Select` for providers that know only `Select`); `ADMIN`-class statements need `Manage` on `System("cluster")` +- `src/auth/` (P3-T002, design `design/P3-T002-authentication.md`, ADR 0046) server authentication: `Role` (viewer < operator < admin), `Credentials` / `Authenticated` / `AuthFailure` (Spark's two `UNAUTHENTICATED` messages), the `Authenticator` trait and `ServerAuth` (throttle, then static tokens; one `krishiv::auth` warn record and one `(surface, method)` counter per failure, never the credential); `config.rs` `AuthConfig::from_settings` (`krishiv.auth.tokens..{secret,principal,kind,role}`, `spark.connect.authenticate.token` = one admin token named `spark`, `krishiv.auth.failureThrottle.*`; unknown `krishiv.auth.*` keys refused); `secret_ref.rs` `env:`/`file:` references (a file readable by group or others refuses startup); `tokens.rs` SHA-256 digests compared in constant time against every entry (secrets >= 32 bytes, no two tokens share one); `throttle.rs` per-address failure blocks, bounded at 65,536 addresses; `mtls.rs` client certificates (`krishiv.auth.clientCertificates` off/optional/required, `clientCa`, `mtls.principalFrom` san-uri/san-dns/cn: exactly one value of that kind or refused, `mtls.userPrincipals`; roles from `krishiv.auth.roles.{viewer,operator,admin}` lists via `config.rs` `RoleLists`; a bearer token next to a certificate must name the same principal; `required` never falls back to a token); `der.rs` `CertNames::from_der`, the bounded no-recursion DER walk of the leaf's subject CN and DNS/URI SANs (fuzz target `fuzz/fuzz_targets/cert_names.rs` includes it by path); `tls_config.rs` `TlsConfig` (`krishiv.server.tls.{certFile,keyFile,minVersion}`) and `check_listener` (startup refusals: non-loopback without auth, non-loopback tokens without TLS, client certificates without TLS); `tls.rs` (feature `tls`) `ServerTls`, the rustls `ServerConfig` on aws-lc-rs with h2/http1.1 ALPN, WebPKI client verifier, a key file checked like a secret file and `reload()` swapping the certified key (a failed reload keeps the old one); `mtls_tests.rs` with in-memory handshakes against `tests/fixtures/tls/`; `oidc_config.rs` `OidcConfig` (`krishiv.auth.oidc.{issuer,audience,jwksUri,algorithms,principalClaim,roleClaim,clockSkew,jwksRefresh,allowInsecureIssuer}` and `krishiv.auth.oidc.roles.{viewer,operator,admin}`, the role-claim values by role, a namespace separate from the certificate names of `krishiv.auth.roles.*`; refused without feature `oidc`; locations https only unless the test flag, with a host, no userinfo, no fragment); `jose.rs` the pure JWS/JWK set/discovery parsers (16 KiB token cap, canonical unpadded base64url, duplicate JSON members refused, `alg` only RS256/384/512 and ES256/384, `crit` refused, header key members ignored; `check_claims`: `exp`/`nbf` with skew, exact `iss`, `aud`, principal and groups; fuzz target `fuzz/fuzz_targets/jwt_parse.rs` includes it by path); `oidc.rs` (feature `oidc`) signatures on aws-lc-rs (no `none`, no HMAC), `JwksCache` (an unknown `kid` asks for one fetch per minute, the first such request waits for it, others fail at once; a token without `kid` needs exactly one fitting key; a failed fetch or a set with no usable key keeps the keys; no keys at all is `Unavailable`) and the `krishiv-jwks` fetch thread (`HttpKeySource`: blocking reqwest, no redirects, 1 MiB cap, one 10 s deadline per fetch); the role of an OIDC principal is the highest any of its role-claim values has in the OIDC role lists; only an invalid token counts toward the throttle; `jose_tests.rs` (every build), `oidc_tests.rs` with keys generated per run, `oidc_http_tests.rs` against a loopback HTTP server; `roles.rs` `RolePolicy`, the ADR 0046 §3 matrix as an `AccessPolicy` (viewer: `Browse`, own `system.*` rows, `/metrics`; operator: `Select`, `Usage`, `Monitor`, `Operate`; admin: `Modify`/`Create`/`Drop`/`Manage`; client code at `krishiv.auth.udf.minRole`; `Impersonate` never; no role denies all), `tests/roles_matrix.rs`. Metadata statements need `Browse` (`authorize` falls back to `Select` for providers that know only `Select`); `ADMIN`-class statements need `Manage` on `System("cluster")` - `src/partitions.rs` partitioned and bucketed tables: the layout `CREATE TABLE` stores (partition columns last), the partitions the catalog tracks (`ADD PARTITION`, `SET LOCATION` and static `INSERT` values cast to the column types, `DROP PARTITION` of a managed table: log first, then the directories; each partitioned table has a read/write lock (`InMemoryCatalogStore::partition_ddl` / `partition_write`, keyed by location, weak entries): `RENAME`/`DROP PARTITION`, `MSCK REPAIR` and `TRUNCATE` hold it exclusively (across their log commit retries) and re-read the entry under it, an `INSERT` holds it shared from its write to its partition registration; external tables refused, registered after `INSERT`/CTAS), `DESCRIBE ... PARTITION`, `SHOW PARTITIONS`, `ANALYZE ... PARTITION`; `tests/partitioned_tables.rs` - `src/column_stats.rs` `ANALYZE TABLE .. FOR COLUMNS` (`CommandUtils.statExprs`): one aggregate over the table, kept in `CatalogTable::column_stats` (the catalog is in memory, no on-disk format), printed by `DESCRIBE EXTENDED t col`; a write, `TRUNCATE`, `ADD PARTITION`, `DROP PARTITION` or `SET LOCATION` drops them (`updateTableStats`); published only to the entry the aggregate read (`CatalogTable::incarnation`, set by the store at create, kept by `replace_table`), so a table dropped and created again meanwhile gets none, nor one whose data changed after it was read (`CatalogTable::data_version`, moved by `data_changed`, which every stats-clearing change calls); `MSCK REPAIR` keeps them, as Spark 4.2.0 does; `tests/char_varchar_write.rs` - `src/table_commands/files.rs` a managed table's directory: `authorize_path` (the policy per privilege), `deletable_dir` (inside the root it was created under, no links, `MODIFY`), `delete_table_files`, `remove_dir` diff --git a/crates/krishiv-session/Cargo.toml b/crates/krishiv-session/Cargo.toml index 463cb2e7b..a2b40a577 100644 --- a/crates/krishiv-session/Cargo.toml +++ b/crates/krishiv-session/Cargo.toml @@ -27,8 +27,11 @@ krishiv-plan-codec = { path = "../krishiv-plan-codec", version = "=0.2.0" } krishiv-sql = { path = "../krishiv-sql", version = "=0.2.0" } krishiv-types = { path = "../krishiv-types", version = "=0.2.0" } krishiv-udf-sdk = { path = "../krishiv-udf-sdk", version = "=0.2.0" } -# The `EXPLAIN VERBOSE` JSON document. +# The `EXPLAIN VERBOSE` JSON document; OIDC token and JWK set parsing (`auth/jose.rs`). +serde = { workspace = true, features = ["std"] } serde_json = { workspace = true, features = ["std"] } +# OIDC signature checks (P3-T002 PR-14d; design §3.3): RSA PKCS#1 v1.5 and ECDSA, feature `oidc`. +aws-lc-rs = { workspace = true, optional = true } # Static token digests (`auth/tokens.rs`, P3-T002). sha2.workspace = true # Row hashes for the agent table (`xxhash3_128`, v1 frozen in `agent.rs`). @@ -71,6 +74,9 @@ cloud = ["krishiv-exec/cloud", "krishiv-io/cloud"] # Server TLS for the listeners (`auth::ServerTls`: rustls on aws-lc-rs, reloadable certificate, # client-certificate verification). `krishiv-server`'s `server` feature turns it on. tls = ["dep:rustls"] +# OIDC access tokens (`auth::OidcConfig`): JWT signatures on aws-lc-rs, the JWK set fetched over +# HTTPS (reqwest, rustls). Off by default; a server build turns it on. +oidc = ["dep:aws-lc-rs", "dep:reqwest"] # Access to the logical-plan IR (`Session::from_plan`, `DataFrame::plan`): # for the Python facade and tests; not covered by semver. unstable-ir = [] diff --git a/crates/krishiv-session/src/auth/config.rs b/crates/krishiv-session/src/auth/config.rs index 804154be4..e8f773efb 100644 --- a/crates/krishiv-session/src/auth/config.rs +++ b/crates/krishiv-session/src/auth/config.rs @@ -17,11 +17,13 @@ //! krishiv.auth.clientCa = file:/path (PEM; with client certificates) //! krishiv.auth.mtls.principalFrom = san-uri | san-dns | cn (default san-uri) //! krishiv.auth.mtls.userPrincipals = alice, bob (certificate principals that are users) -//! krishiv.auth.roles.{viewer,operator,admin} = a, b (principal names; ADR 0046 §6) +//! krishiv.auth.roles.{viewer,operator,admin} = a, b (certificate principal names; ADR 0046 §6) +//! krishiv.auth.oidc.* (OIDC access tokens; see `oidc_config.rs`) //! ``` //! -//! A static token names its own role. A certificate principal (and, later, an OIDC claim) -//! gets its role from the `krishiv.auth.roles.*` lists; a name in none of them has no role, +//! A static token names its own role. A certificate principal gets its role from the +//! `krishiv.auth.roles.*` lists by its name, an OIDC principal from `krishiv.auth.oidc.roles.*` +//! by the values of its role claim; a name in none of them has no role, //! which denies everything. //! //! Every key is startup-only. An unknown `krishiv.auth.*` key is refused, so a misspelt @@ -35,6 +37,7 @@ use krishiv_common::principal::PrincipalKind; use super::Role; use super::mtls::MtlsConfig; +use super::oidc_config::OidcConfig; use super::secret_ref::{SecretRef, invalid}; /// The key prefix of every Krishiv authentication setting. @@ -98,8 +101,10 @@ pub struct AuthConfig { pub throttle: ThrottleConfig, /// Client certificates. pub mtls: MtlsConfig, - /// The roles of principals that do not carry one (certificates). + /// The roles of principals that do not carry one (certificates, OIDC groups). pub roles: RoleLists, + /// OIDC access tokens. + pub oidc: OidcConfig, } /// `krishiv.auth.roles.{viewer,operator,admin}`: principal names by role. @@ -116,7 +121,7 @@ impl RoleLists { } /// Sets the list of `role` from the setting `key = raw` (comma separated names). - fn set(&mut self, key: &str, role: Role, raw: &str) -> Result<(), KrishivError> { + pub(super) fn set(&mut self, key: &str, role: Role, raw: &str) -> Result<(), KrishivError> { self.by_name.retain(|_, r| *r != role); for name in raw.split(',').map(str::trim).filter(|s| !s.is_empty()) { if let Some(other) = self.by_name.get(name) @@ -160,6 +165,7 @@ impl AuthConfig { let mut spark_principal = SPARK_TOKEN_NAME.to_owned(); let mut throttle = ThrottleConfig::default(); let mut mtls = MtlsConfig::default(); + let mut oidc = OidcConfig::default(); let mut role_lists: BTreeMap = BTreeMap::new(); for (key, raw) in settings { if key == SPARK_TOKEN_KEY { @@ -186,7 +192,7 @@ impl AuthConfig { } continue; } - if mtls.apply(key, rest, raw)? { + if mtls.apply(key, rest, raw)? || oidc.apply(key, raw)? { continue; } if let Some(role) = rest.strip_prefix("roles.") { @@ -244,6 +250,7 @@ impl AuthConfig { }); } mtls.validate()?; + oidc.validate()?; // Each list once, whole, so a key given twice keeps its last value. let mut roles = RoleLists::default(); for (role, (key, raw)) in role_lists { @@ -254,13 +261,21 @@ impl AuthConfig { throttle, mtls, roles, + oidc, }) } /// Whether any authentication method is configured. #[must_use] pub fn is_configured(&self) -> bool { - !self.tokens.is_empty() || self.mtls.is_on() + !self.tokens.is_empty() || self.mtls.is_on() || self.oidc.is_on() + } + + /// Whether a bearer token can authenticate (static tokens or OIDC): such a server needs TLS + /// off loopback. + #[must_use] + pub fn has_bearer_method(&self) -> bool { + !self.tokens.is_empty() || self.oidc.is_on() } } @@ -288,7 +303,7 @@ fn parse_kind(key: &str, raw: &str) -> Result { } } -fn parse_duration(key: &str, raw: &str) -> Result { +pub(super) fn parse_duration(key: &str, raw: &str) -> Result { let s = raw.trim(); let split = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()); let (digits, unit) = s.split_at(split); diff --git a/crates/krishiv-session/src/auth/der.rs b/crates/krishiv-session/src/auth/der.rs index e6a1958c6..e8284e88f 100644 --- a/crates/krishiv-session/src/auth/der.rs +++ b/crates/krishiv-session/src/auth/der.rs @@ -274,7 +274,7 @@ fn text(tag: u8, bytes: &[u8]) -> Result { /// tables (the Unicode `Cf` code points a name could plausibly carry): soft hyphen, zero-width /// and joiner characters, bidirectional marks, embeddings, overrides and isolates, invisible /// operators and the byte order mark. -fn is_format(c: char) -> bool { +pub(crate) fn is_format(c: char) -> bool { matches!( c, '\u{00ad}' diff --git a/crates/krishiv-session/src/auth/jose.rs b/crates/krishiv-session/src/auth/jose.rs new file mode 100644 index 000000000..7ad72eb4c --- /dev/null +++ b/crates/krishiv-session/src/auth/jose.rs @@ -0,0 +1,610 @@ +// SPDX-License-Identifier: Apache-2.0 +//! JWS compact tokens and JWK sets, parsed but not verified (design §3.3, steps 1, 2, 4 and 6). +//! +//! Everything here is a pure function of bytes: no clock, no network, no cryptography. The +//! signature check and the key cache are in `oidc.rs`. The fuzz target `jwt_parse` includes +//! this file by path, so it uses only `std`, `serde` and `serde_json`. +//! +//! - Tokens are capped at [`MAX_TOKEN_BYTES`]; base64url is the unpadded, canonical alphabet of +//! RFC 7515 §2 (padding, other characters and non-zero trailing bits are refused). +//! - A JSON object that names a member twice is refused (RFC 7515 §5.2 step 4, RFC 7519 §4): +//! otherwise the header's `alg` or a claim could mean two things. +//! - `alg` is one of RS256, RS384, RS512, ES256 and ES384. `none` and every HMAC algorithm are +//! not parsed at all, so no configuration can accept them. `crit` is refused. The header's +//! key-locating members (`jku`, `jwk`, `x5u`, `x5c`) are never used: keys come only from the +//! configured JWK set. + +use std::collections::BTreeMap; +use std::fmt; + +use serde::de::{self, Deserializer, IgnoredAny, MapAccess, Visitor}; +use serde_json::Value; + +/// The largest token parsed (design §3.3 step 1). +pub(crate) const MAX_TOKEN_BYTES: usize = 16 * 1024; +/// The largest JWK set or discovery document read. +pub(crate) const MAX_DOCUMENT_BYTES: usize = 1024 * 1024; +/// The most keys a JWK set may hold. +pub(crate) const MAX_JWKS_KEYS: usize = 64; +/// The longest principal name taken from a claim. +pub(crate) const MAX_PRINCIPAL_BYTES: usize = 256; +/// The most group values taken from the role claim. +pub(crate) const MAX_GROUPS: usize = 256; + +/// A JWS signature algorithm Krishiv verifies (RFC 7518 §3.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[non_exhaustive] +pub enum Alg { + /// RSASSA-PKCS1-v1_5 with SHA-256. + Rs256, + /// RSASSA-PKCS1-v1_5 with SHA-384. + Rs384, + /// RSASSA-PKCS1-v1_5 with SHA-512. + Rs512, + /// ECDSA on P-256 with SHA-256. + Es256, + /// ECDSA on P-384 with SHA-384. + Es384, +} + +impl Alg { + /// Every algorithm. + pub const ALL: [Self; 5] = [ + Self::Rs256, + Self::Rs384, + Self::Rs512, + Self::Es256, + Self::Es384, + ]; + + /// The JOSE name. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Rs256 => "RS256", + Self::Rs384 => "RS384", + Self::Rs512 => "RS512", + Self::Es256 => "ES256", + Self::Es384 => "ES384", + } + } + + /// The algorithm named exactly `s` (JOSE names are case-sensitive). + #[must_use] + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|a| a.as_str() == s) + } + + /// The curve an ECDSA algorithm needs; `None` for RSA. + #[must_use] + pub(crate) fn curve(self) -> Option { + match self { + Self::Es256 => Some(Curve::P256), + Self::Es384 => Some(Curve::P384), + Self::Rs256 | Self::Rs384 | Self::Rs512 => None, + } + } +} + +/// An elliptic curve of an EC key. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub(crate) enum Curve { + /// NIST P-256. + P256, + /// NIST P-384. + P384, +} + +impl Curve { + /// The byte length of one coordinate. + #[must_use] + pub(crate) fn coordinate_len(self) -> usize { + match self { + Self::P256 => 32, + Self::P384 => 48, + } + } + + fn parse(s: &str) -> Option { + match s { + "P-256" => Some(Self::P256), + "P-384" => Some(Self::P384), + _ => None, + } + } +} + +/// Why a token or document was refused. Never carries token content. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub(crate) enum JoseError { + /// Refused, with a fixed reason for tests and logs. + Invalid(&'static str), + /// The token's `exp` has passed. + Expired, +} + +impl fmt::Display for JoseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(why) => f.write_str(why), + Self::Expired => f.write_str("expired"), + } + } +} + +/// A JSON object whose members are all distinct. +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct Object(pub BTreeMap); + +impl<'de> serde::Deserialize<'de> for Object { + fn deserialize>(d: D) -> Result { + struct V; + impl<'de> Visitor<'de> for V { + type Value = Object; + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a JSON object") + } + fn visit_map>(self, mut map: A) -> Result { + let mut out = BTreeMap::new(); + while let Some(k) = map.next_key::()? { + let v: Value = map.next_value()?; + if out.insert(k, v).is_some() { + return Err(de::Error::custom("duplicate member")); + } + } + Ok(Object(out)) + } + } + d.deserialize_map(V) + } +} + +impl Object { + fn parse(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|_| JoseError::Invalid("not a JSON object")) + } + + /// The string member `name`; `Ok(None)` when absent, an error when not a string. + fn opt_str(&self, name: &str) -> Result, JoseError> { + match self.0.get(name) { + None => Ok(None), + Some(Value::String(s)) => Ok(Some(s)), + Some(_) => Err(JoseError::Invalid("member is not a string")), + } + } +} + +/// A token's protected header. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub(crate) struct Header { + /// The signature algorithm. + pub(crate) alg: Alg, + /// The key id, if given. + pub(crate) kid: Option, +} + +/// A JWS compact token, split and decoded; its signature is not checked yet. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub(crate) struct Jws<'a> { + /// The protected header. + pub(crate) header: Header, + /// The claims set. + pub(crate) claims: Object, + /// `base64url(header) '.' base64url(payload)`: the bytes the signature covers. + pub(crate) signing_input: &'a [u8], + /// The decoded signature. + pub(crate) signature: Vec, +} + +/// Whether `token` has the shape of a JWS compact token: three base64url parts whose first +/// decodes to a JSON object with an `alg` (design §3.1: such a bearer goes to OIDC, any other +/// to the static tokens). Says nothing about whether it is valid. +#[must_use] +pub(crate) fn looks_like_jws(token: &str) -> bool { + let mut parts = token.split('.'); + let (Some(h), Some(p), Some(s), None) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + return false; + }; + if token.len() > MAX_TOKEN_BYTES || p.is_empty() || s.is_empty() { + return false; + } + b64url_decode(h) + .ok() + .and_then(|b| Object::parse(&b).ok()) + .is_some_and(|o| matches!(o.0.get("alg"), Some(Value::String(_)))) +} + +/// Splits and decodes `token` (design §3.3 steps 1 and 2). +/// +/// # Errors +/// [`JoseError::Invalid`] for a token too long, not three base64url parts, a header or payload +/// that is not a JSON object with distinct members, an `alg` Krishiv does not verify, a `crit` +/// header, or a `typ` other than `JWT` or `at+jwt` (RFC 9068). +pub(crate) fn parse_jws(token: &str) -> Result, JoseError> { + if token.len() > MAX_TOKEN_BYTES { + return Err(JoseError::Invalid("token too long")); + } + let mut parts = token.splitn(4, '.'); + let (Some(h), Some(p), Some(s), None) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + return Err(JoseError::Invalid("not three parts")); + }; + let header = Object::parse(&b64url_decode(h)?)?; + let alg = match header.opt_str("alg")? { + Some(a) => Alg::parse(a).ok_or(JoseError::Invalid("algorithm not supported"))?, + None => return Err(JoseError::Invalid("no algorithm")), + }; + if header.0.contains_key("crit") { + return Err(JoseError::Invalid("critical header extensions")); + } + if let Some(typ) = header.opt_str("typ")? { + let typ = typ.strip_prefix("application/").unwrap_or(typ); + if !typ.eq_ignore_ascii_case("JWT") && !typ.eq_ignore_ascii_case("at+jwt") { + return Err(JoseError::Invalid("not a JWT")); + } + } + let kid = header.opt_str("kid")?.map(str::to_owned); + let claims = Object::parse(&b64url_decode(p)?)?; + let signature = b64url_decode(s)?; + if signature.is_empty() { + return Err(JoseError::Invalid("empty signature")); + } + let signing_input = &token.as_bytes()[..h.len() + 1 + p.len()]; + Ok(Jws { + header: Header { alg, kid }, + claims, + signing_input, + signature, + }) +} + +/// Decodes unpadded base64url (RFC 7515 §2), refusing anything non-canonical. +/// +/// # Errors +/// [`JoseError::Invalid`] for padding, a character outside the alphabet, a length of 1 mod 4, +/// or non-zero trailing bits. +pub(crate) fn b64url_decode(s: &str) -> Result, JoseError> { + const BAD: JoseError = JoseError::Invalid("not base64url"); + fn value(c: u8) -> Option { + let v = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'-' => 62, + b'_' => 63, + _ => return None, + }; + Some(u32::from(v)) + } + let bytes = s.as_bytes(); + if bytes.len() % 4 == 1 { + return Err(BAD); + } + let mut out = Vec::with_capacity(bytes.len() / 4 * 3 + 2); + for chunk in bytes.chunks(4) { + let mut acc: u32 = 0; + for &c in chunk { + acc = (acc << 6) | value(c).ok_or(BAD)?; + } + // The chunk's bits, left aligned to 24: 4 chars = 3 bytes, 3 = 2, 2 = 1. + let bits = chunk.len() * 6; + let bytes_out = bits / 8; + let spare = bits - bytes_out * 8; + if acc & ((1 << spare) - 1) != 0 { + return Err(BAD); + } + let acc = acc >> spare; + for i in (0..bytes_out).rev() { + out.push(((acc >> (8 * i)) & 0xff) as u8); + } + } + Ok(out) +} + +/// What the claims must hold (design §3.3 step 6; ADR 0046 §6). +#[derive(Debug, Clone, Copy)] +pub(crate) struct ClaimRules<'a> { + /// The exact `iss`. + pub(crate) issuer: &'a str, + /// A value `aud` must contain. + pub(crate) audience: &'a str, + /// The claim naming the principal (`sub` by default). + pub(crate) principal_claim: &'a str, + /// The claim holding the groups (`groups` by default): a string or an array of strings. + pub(crate) role_claim: &'a str, + /// Allowed clock difference, in seconds. + pub(crate) skew_secs: u64, +} + +/// Who a valid token names. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub(crate) struct Identity { + /// The principal claim's value. + pub(crate) principal: String, + /// The role claim's values. + pub(crate) groups: Vec, +} + +/// Checks the claims of a token whose signature was verified, at `now` (seconds since the Unix +/// epoch). +/// +/// # Errors +/// [`JoseError::Expired`] once `exp` (plus the skew) has passed; [`JoseError::Invalid`] for a +/// missing or non-numeric `exp`, an `nbf` still ahead, another `iss`, an `aud` without the +/// audience, a missing, empty, too long or control-character principal, or a role claim that is +/// not a string or an array of at most [`MAX_GROUPS`] strings. +pub(crate) fn check_claims( + claims: &Object, + rules: &ClaimRules<'_>, + now: u64, +) -> Result { + // Seconds as f64: a NumericDate may be fractional (RFC 7519 §2); any time this century is + // exact in an f64. + #[expect( + clippy::cast_precision_loss, + reason = "seconds since 1970 fit an f64 exactly" + )] + let (now, skew) = (now as f64, rules.skew_secs as f64); + let date = |name: &str| match claims.0.get(name) { + None => Ok(None), + Some(v) => v + .as_f64() + .filter(|d| d.is_finite()) + .map(Some) + .ok_or(JoseError::Invalid("date claim is not a number")), + }; + let exp = date("exp")?.ok_or(JoseError::Invalid("no exp"))?; + if now >= exp + skew { + return Err(JoseError::Expired); + } + if date("nbf")?.is_some_and(|nbf| now + skew < nbf) { + return Err(JoseError::Invalid("not valid yet")); + } + if claims.opt_str("iss")? != Some(rules.issuer) { + return Err(JoseError::Invalid("issuer")); + } + let audience_ok = match claims.0.get("aud") { + Some(Value::String(a)) => a == rules.audience, + Some(Value::Array(list)) => list.iter().any(|a| a.as_str() == Some(rules.audience)), + _ => false, + }; + if !audience_ok { + return Err(JoseError::Invalid("audience")); + } + let principal = claims + .opt_str(rules.principal_claim)? + .filter(|p| is_name(p)) + .ok_or(JoseError::Invalid("principal claim"))? + .to_owned(); + let groups = match claims.0.get(rules.role_claim) { + None => Vec::new(), + Some(Value::String(g)) => vec![g.clone()], + Some(Value::Array(list)) if list.len() <= MAX_GROUPS => list + .iter() + .map(|g| g.as_str().map(str::to_owned)) + .collect::>>() + .ok_or(JoseError::Invalid("role claim"))?, + Some(_) => return Err(JoseError::Invalid("role claim")), + }; + Ok(Identity { principal, groups }) +} + +/// A usable principal name: non-empty, at most [`MAX_PRINCIPAL_BYTES`], no control or format +/// characters (it is logged and shown in `system.queries`). +fn is_name(s: &str) -> bool { + !s.is_empty() + && s.len() <= MAX_PRINCIPAL_BYTES + && !s.chars().any(|c| c.is_control() || is_format_char(c)) +} + +/// Invisible format characters that make two names look alike or reorder text in logs and +/// tables: the same set the certificate name rule refuses (`der.rs` `is_format`; a test checks +/// that both agree). +pub(crate) fn is_format_char(c: char) -> bool { + matches!( + c, + '\u{00ad}' + | '\u{061c}' + | '\u{180e}' + | '\u{200b}'..='\u{200f}' + | '\u{202a}'..='\u{202e}' + | '\u{2060}'..='\u{2064}' + | '\u{2066}'..='\u{206f}' + | '\u{feff}' + | '\u{fff9}'..='\u{fffb}' + ) +} + +/// The public part of a JSON Web Key (RFC 7517) Krishiv can verify with. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub(crate) enum KeyMaterial { + /// An RSA key: modulus and exponent, big-endian without leading zeros. + Rsa { + /// The modulus. + n: Vec, + /// The public exponent. + e: Vec, + }, + /// An EC key: the uncompressed point `04 || x || y`. + Ec { + /// The curve. + curve: Curve, + /// The point. + point: Vec, + }, +} + +/// One signing key of a JWK set. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub(crate) struct Jwk { + /// `kid`, if given. + pub(crate) kid: Option, + /// `alg`, if given: the key may then only verify that algorithm. + pub(crate) alg: Option, + /// The key. + pub(crate) material: KeyMaterial, +} + +impl Jwk { + /// Whether this key may verify a token signed with `alg` (design §3.3 step 4): the key type + /// and curve fit the algorithm, and the key's own `alg`, if any, is the same. + #[must_use] + pub(crate) fn fits(&self, alg: Alg) -> bool { + if self.alg.is_some_and(|a| a != alg) { + return false; + } + match (&self.material, alg.curve()) { + (KeyMaterial::Rsa { .. }, None) => true, + (KeyMaterial::Ec { curve, .. }, Some(c)) => *curve == c, + _ => false, + } + } +} + +/// The `keys` member of a JWK set document, each key with distinct members. +struct JwksDocument(Vec); + +impl<'de> serde::Deserialize<'de> for JwksDocument { + fn deserialize>(d: D) -> Result { + struct V; + impl<'de> Visitor<'de> for V { + type Value = JwksDocument; + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a JWK set") + } + fn visit_map>(self, mut map: A) -> Result { + let mut keys = None; + while let Some(k) = map.next_key::()? { + if k == "keys" { + if keys.is_some() { + return Err(de::Error::custom("duplicate member")); + } + keys = Some(map.next_value::>()?); + } else { + map.next_value::()?; + } + } + keys.map(JwksDocument) + .ok_or_else(|| de::Error::missing_field("keys")) + } + } + d.deserialize_map(V) + } +} + +/// Parses a JWK set document, keeping the signature keys Krishiv can use. +/// +/// Keys of another type (`oct`, `OKP`), another curve, `use` other than `sig`, `key_ops` +/// without `verify`, an `alg` Krishiv does not verify, or with malformed numbers are skipped, +/// so one odd key does not take the others down. +/// +/// # Errors +/// [`JoseError::Invalid`] for a document larger than [`MAX_DOCUMENT_BYTES`], not a JSON object +/// with a `keys` array of objects, or with more than [`MAX_JWKS_KEYS`] keys. +pub(crate) fn parse_jwks(bytes: &[u8]) -> Result, JoseError> { + if bytes.len() > MAX_DOCUMENT_BYTES { + return Err(JoseError::Invalid("JWK set too large")); + } + let JwksDocument(keys) = + serde_json::from_slice(bytes).map_err(|_| JoseError::Invalid("not a JWK set"))?; + if keys.len() > MAX_JWKS_KEYS { + return Err(JoseError::Invalid("too many keys")); + } + Ok(keys.iter().filter_map(|k| jwk(k).ok().flatten()).collect()) +} + +/// One key; `Ok(None)` for a key Krishiv does not use. +fn jwk(k: &Object) -> Result, JoseError> { + if k.opt_str("use")?.is_some_and(|u| u != "sig") { + return Ok(None); + } + if let Some(ops) = k.0.get("key_ops") { + let verify = ops + .as_array() + .is_some_and(|ops| ops.iter().any(|o| o.as_str() == Some("verify"))); + if !verify { + return Ok(None); + } + } + let alg = match k.opt_str("alg")? { + None => None, + Some(a) => match Alg::parse(a) { + Some(a) => Some(a), + None => return Ok(None), + }, + }; + let field = |name: &str| -> Result, JoseError> { + b64url_decode( + k.opt_str(name)? + .ok_or(JoseError::Invalid("missing key member"))?, + ) + }; + let material = match k.opt_str("kty")? { + Some("RSA") => { + let n = strip_zeros(field("n")?); + let e = strip_zeros(field("e")?); + if n.is_empty() || e.is_empty() { + return Ok(None); + } + KeyMaterial::Rsa { n, e } + } + Some("EC") => { + let Some(curve) = k.opt_str("crv")?.and_then(Curve::parse) else { + return Ok(None); + }; + let (x, y) = (field("x")?, field("y")?); + let len = curve.coordinate_len(); + if x.len() != len || y.len() != len { + return Ok(None); + } + let mut point = Vec::with_capacity(1 + 2 * len); + point.push(0x04); + point.extend_from_slice(&x); + point.extend_from_slice(&y); + KeyMaterial::Ec { curve, point } + } + _ => return Ok(None), + }; + let jwk = Jwk { + kid: k.opt_str("kid")?.map(str::to_owned), + alg, + material, + }; + // A key whose own `alg` does not fit its type is unusable. + Ok(jwk.alg.is_none_or(|a| jwk.fits(a)).then_some(jwk)) +} + +fn strip_zeros(mut v: Vec) -> Vec { + let first = v.iter().position(|b| *b != 0).unwrap_or(v.len()); + v.drain(..first); + v +} + +/// The `jwks_uri` of an OpenID Provider configuration document (OpenID Connect Discovery 1.0 +/// §3), after checking that its `issuer` is `issuer` exactly (§4.3). +/// +/// # Errors +/// [`JoseError::Invalid`] for a document too large, not a JSON object, with another issuer or +/// without a string `jwks_uri`. +pub(crate) fn discovered_jwks_uri(bytes: &[u8], issuer: &str) -> Result { + if bytes.len() > MAX_DOCUMENT_BYTES { + return Err(JoseError::Invalid("discovery document too large")); + } + let doc = Object::parse(bytes)?; + if doc.opt_str("issuer")? != Some(issuer) { + return Err(JoseError::Invalid("discovery issuer")); + } + doc.opt_str("jwks_uri")? + .map(str::to_owned) + .ok_or(JoseError::Invalid("no jwks_uri")) +} diff --git a/crates/krishiv-session/src/auth/jose_tests.rs b/crates/krishiv-session/src/auth/jose_tests.rs new file mode 100644 index 000000000..e20da4111 --- /dev/null +++ b/crates/krishiv-session/src/auth/jose_tests.rs @@ -0,0 +1,487 @@ +// SPDX-License-Identifier: Apache-2.0 +//! The OIDC parsers and settings, without cryptography (so they run in every build): base64url, +//! the token shape, header refusals, JWK sets, discovery, claim checks and `krishiv.auth.oidc.*`. +//! Signatures and the key cache are tested in `oidc_tests.rs` (feature `oidc`). + +use std::time::Duration; + +use serde_json::{Value, json}; + +use super::jose::{self, Alg, ClaimRules, JoseError, KeyMaterial}; +use super::oidc_config::OidcConfig; + +const ISSUER: &str = "https://idp.example.test/"; +const NOW: u64 = 1_800_000_000; + +fn b64(bytes: &[u8]) -> String { + const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::new(); + for chunk in bytes.chunks(3) { + let mut acc = 0u32; + for (i, b) in chunk.iter().enumerate() { + acc |= u32::from(*b) << (16 - 8 * i); + } + for i in 0..=chunk.len() { + out.push(char::from(A[((acc >> (18 - 6 * i)) & 63) as usize])); + } + } + out +} + +fn token_with_header(header: &str) -> String { + format!( + "{}.{}.c2ln", + b64(header.as_bytes()), + b64(b"{\"sub\":\"a\"}") + ) +} + +fn rules() -> ClaimRules<'static> { + ClaimRules { + issuer: ISSUER, + audience: "krishiv", + principal_claim: "sub", + role_claim: "groups", + skew_secs: 60, + } +} + +fn claims(v: &Value) -> jose::Object { + serde_json::from_value(v.clone()).expect("object") +} + +fn good() -> Value { + json!({"iss": ISSUER, "aud": "krishiv", "sub": "alice", "exp": NOW + 10, "groups": ["g1", "g2"]}) +} + +fn check(v: &Value) -> Result { + jose::check_claims(&claims(v), &rules(), NOW) +} + +#[test] +fn base64url_is_strict() { + assert_eq!(jose::b64url_decode("").expect("empty"), b""); + assert_eq!(jose::b64url_decode("TWFu").expect("Man"), b"Man"); + assert_eq!(jose::b64url_decode("TWE").expect("Ma"), b"Ma"); + assert_eq!(jose::b64url_decode("TQ").expect("M"), b"M"); + assert_eq!( + jose::b64url_decode("-_8").expect("url alphabet"), + [0xfb, 0xff] + ); + // Padding, the standard alphabet, a lone character, non-zero trailing bits. + for bad in ["TQ==", "a+b/", "T", "TR", "TWF"] { + assert!(jose::b64url_decode(bad).is_err(), "{bad}"); + } + assert_eq!(b64(b"any carnal pleas"), "YW55IGNhcm5hbCBwbGVhcw"); +} + +#[test] +fn token_shape_and_header_refusals() { + assert!(!jose::looks_like_jws("abc")); + assert!(!jose::looks_like_jws("a.b.c.d")); + assert!(!jose::looks_like_jws("eyJ..")); + assert!(!jose::looks_like_jws(&token_with_header( + r#"{"typ":"JWT"}"# + ))); + assert!(!jose::looks_like_jws(&token_with_header(r#"{"alg":1}"#))); + assert!(jose::looks_like_jws(&token_with_header( + r#"{"alg":"RS256"}"# + ))); + let long = format!( + "{}.{}.c2ln", + b64(br#"{"alg":"RS256"}"#), + "A".repeat(jose::MAX_TOKEN_BYTES) + ); + assert!(!jose::looks_like_jws(&long)); + assert_eq!( + jose::parse_jws(&long).unwrap_err(), + JoseError::Invalid("token too long") + ); + + let es384 = token_with_header(r#"{"alg":"ES384","kid":"k"}"#); + let ok = jose::parse_jws(&es384).expect("parse"); + assert_eq!( + (ok.header.alg, ok.header.kid.as_deref()), + (Alg::Es384, Some("k")) + ); + assert_eq!(ok.signature, b"sig"); + assert_eq!( + ok.signing_input, + es384 + .rsplit_once('.') + .map(|(i, _)| i.as_bytes()) + .expect("dot") + ); + assert_eq!(ok.claims.0.get("sub"), Some(&json!("a"))); + + let refused = |header: &str| jose::parse_jws(&token_with_header(header)).unwrap_err(); + for (header, why) in [ + (r#"{"alg":"none"}"#, "algorithm not supported"), + (r#"{"alg":"HS256"}"#, "algorithm not supported"), + (r#"{"alg":"rs256"}"#, "algorithm not supported"), + (r#"{"alg":"PS256"}"#, "algorithm not supported"), + (r#"{"kid":"k"}"#, "no algorithm"), + (r#"{"alg":["RS256"]}"#, "member is not a string"), + ( + r#"{"alg":"RS256","crit":["b64"]}"#, + "critical header extensions", + ), + (r#"{"alg":"RS256","alg":"none"}"#, "not a JSON object"), + (r#"{"alg":"RS256","typ":"dpop+jwt"}"#, "not a JWT"), + (r#"{"alg":"RS256","kid":7}"#, "member is not a string"), + ("[]", "not a JSON object"), + ] { + assert_eq!(refused(header), JoseError::Invalid(why), "{header}"); + } + for typ in ["JWT", "jwt", "at+jwt", "application/at+JWT"] { + let h = format!(r#"{{"alg":"RS256","typ":"{typ}"}}"#); + assert!(jose::parse_jws(&token_with_header(&h)).is_ok(), "{typ}"); + } + // Two parts, an empty signature, a payload that is not an object, a duplicate claim. + let h = b64(br#"{"alg":"RS256"}"#); + for bad in [ + format!("{h}.e30"), + format!("{h}.e30."), + format!("{h}.{}.c2ln", b64(b"[1]")), + format!("{h}.{}.c2ln", b64(br#"{"sub":"a","sub":"b"}"#)), + format!("{h}.e30.c2ln.x"), + format!("{h}.e3=.c2ln"), + ] { + assert!(jose::parse_jws(&bad).is_err(), "{bad}"); + } +} + +#[test] +fn claims_are_checked() { + let id = check(&good()).expect("valid"); + assert_eq!(id.principal, "alice"); + assert_eq!(id.groups, ["g1", "g2"]); + let with = |name: &str, v: Value| { + let mut c = good(); + if v.is_null() { + c.as_object_mut().expect("object").remove(name); + } else { + c[name] = v; + } + check(&c) + }; + // Expiry with the skew, both sides of the line; fractional dates. + assert_eq!( + with("exp", json!(NOW - 60)).unwrap_err(), + JoseError::Expired + ); + assert!(with("exp", json!(NOW - 59)).is_ok()); + assert!(with("exp", json!(1.8e9 - 59.5)).is_ok()); + assert_eq!( + with("exp", Value::Null).unwrap_err(), + JoseError::Invalid("no exp") + ); + assert_eq!( + with("exp", json!("soon")).unwrap_err(), + JoseError::Invalid("date claim is not a number") + ); + assert!(with("nbf", json!(NOW + 60)).is_ok()); + assert_eq!( + with("nbf", json!(NOW + 61)).unwrap_err(), + JoseError::Invalid("not valid yet") + ); + assert_eq!( + with("iss", json!("https://idp.example.test")).unwrap_err(), + JoseError::Invalid("issuer") + ); + assert_eq!( + with("iss", Value::Null).unwrap_err(), + JoseError::Invalid("issuer") + ); + assert_eq!( + with("iss", json!(1)).unwrap_err(), + JoseError::Invalid("member is not a string") + ); + assert!(with("aud", json!(["x", "krishiv"])).is_ok()); + for aud in [json!("x"), json!([]), json!(["x"]), json!(5), Value::Null] { + assert_eq!( + with("aud", aud).unwrap_err(), + JoseError::Invalid("audience") + ); + } + for sub in [ + json!(""), + json!("a\nb"), + json!("a\u{200B}b"), + json!("x".repeat(257)), + ] { + assert_eq!( + with("sub", sub).unwrap_err(), + JoseError::Invalid("principal claim") + ); + } + assert!(with("sub", json!("x".repeat(256))).is_ok()); + assert_eq!(with("groups", json!("solo")).expect("one").groups, ["solo"]); + assert!(with("groups", Value::Null).expect("none").groups.is_empty()); + let too_many: Vec = (0..=jose::MAX_GROUPS).map(|i| i.to_string()).collect(); + for groups in [json!([1]), json!({"a": 1}), json!(too_many)] { + assert_eq!( + with("groups", groups).unwrap_err(), + JoseError::Invalid("role claim") + ); + } + assert_eq!(JoseError::Expired.to_string(), "expired"); + assert_eq!(JoseError::Invalid("issuer").to_string(), "issuer"); +} + +#[test] +fn jwk_sets_keep_usable_keys() { + let n = b64(&[0, 0, 0xc5, 1, 2]); + let rsa = |kid: &str| json!({"kty": "RSA", "kid": kid, "n": n, "e": "AQAB"}); + let ec = |crv: &str, len: usize| json!({"kty": "EC", "kid": crv, "crv": crv, "x": b64(&vec![1; len]), "y": b64(&vec![2; len])}); + let mut wrong_alg = rsa("wrong-alg"); + wrong_alg["alg"] = json!("ES256"); + let mut enc = rsa("enc"); + enc["use"] = json!("enc"); + let mut ops = rsa("ops"); + ops["key_ops"] = json!(["encrypt"]); + let mut verify = rsa("verify"); + verify["key_ops"] = json!(["verify"]); + verify["alg"] = json!("RS512"); + verify["use"] = json!("sig"); + let mut unknown_alg = rsa("unknown-alg"); + unknown_alg["alg"] = json!("PS256"); + let set = json!({"keys": [ + {"kty": "oct", "k": "c2VjcmV0"}, + {"kty": "OKP", "crv": "Ed25519", "x": "AA"}, + ec("P-521", 66), + {"kty": "EC", "crv": "P-256", "x": "AQ", "y": "Ag"}, + {"kty": "RSA", "n": "not base64!", "e": "AQAB"}, + {"kty": "RSA", "n": "AAAA", "e": "AQAB"}, + {"kty": "RSA", "e": "AQAB"}, + wrong_alg, enc, ops, unknown_alg, + rsa("k1"), verify, ec("P-256", 32), ec("P-384", 48), + ], "other": 1}); + let keys = jose::parse_jwks(&serde_json::to_vec(&set).expect("json")).expect("parse"); + let kids: Vec<_> = keys.iter().filter_map(|k| k.kid.as_deref()).collect(); + assert_eq!(kids, ["k1", "verify", "P-256", "P-384"]); + let KeyMaterial::Rsa { n, e } = &keys[0].material else { + panic!("an RSA key"); + }; + assert_eq!( + (n.as_slice(), e.as_slice()), + (&[0xc5, 1, 2][..], &[1, 0, 1][..]) + ); + let KeyMaterial::Ec { point, curve } = &keys[3].material else { + panic!("an EC key"); + }; + assert_eq!((point.len(), point[0], *curve), (97, 4, jose::Curve::P384)); + assert!(keys[1].fits(Alg::Rs512) && !keys[1].fits(Alg::Rs256)); + assert!(keys[2].fits(Alg::Es256) && !keys[2].fits(Alg::Es384) && !keys[2].fits(Alg::Rs256)); + assert!(keys[0].fits(Alg::Rs384) && !keys[0].fits(Alg::Es256)); + + for bad in [ + &br#"{"keys": [], "keys": []}"#[..], + b"{}", + b"[]", + b"{\"keys\": [1]}", + b"{\"keys\": [{\"kty\": \"RSA\", \"kty\": \"EC\"}]}", + ] { + assert!( + jose::parse_jwks(bad).is_err(), + "{}", + String::from_utf8_lossy(bad) + ); + } + let many = json!({"keys": vec![rsa("k"); jose::MAX_JWKS_KEYS + 1]}); + assert_eq!( + jose::parse_jwks(&serde_json::to_vec(&many).expect("json")).unwrap_err(), + JoseError::Invalid("too many keys") + ); + let huge = vec![b' '; jose::MAX_DOCUMENT_BYTES + 1]; + assert!(jose::parse_jwks(&huge).is_err()); + assert!(jose::discovered_jwks_uri(&huge, ISSUER).is_err()); +} + +#[test] +fn discovery_needs_the_same_issuer() { + let doc = json!({"issuer": ISSUER, "jwks_uri": "https://idp.example.test/keys"}).to_string(); + assert_eq!( + jose::discovered_jwks_uri(doc.as_bytes(), ISSUER).expect("uri"), + "https://idp.example.test/keys" + ); + assert!(jose::discovered_jwks_uri(doc.as_bytes(), "https://evil.test/").is_err()); + let no_uri = json!({"issuer": ISSUER}).to_string(); + assert!(jose::discovered_jwks_uri(no_uri.as_bytes(), ISSUER).is_err()); +} + +#[test] +fn algorithm_names() { + for alg in Alg::ALL { + assert_eq!(Alg::parse(alg.as_str()), Some(alg)); + } + assert_eq!(Alg::parse("ES512"), None); + assert_eq!(Alg::Rs256.curve(), None); + assert_eq!( + Alg::Es256.curve().map(jose::Curve::coordinate_len), + Some(32) + ); +} + +fn parse(kv: &[(&str, &str)]) -> Result { + let mut c = OidcConfig::default(); + for (k, v) in kv { + if !c.apply(k, v).map_err(|e| e.to_string())? { + return Err(format!("not an OIDC key: {k}")); + } + } + c.validate_values().map_err(|e| e.to_string())?; + Ok(c) +} + +#[test] +fn oidc_settings() { + let c = parse(&[ + ("krishiv.auth.oidc.issuer", ISSUER), + ("krishiv.auth.oidc.audience", "krishiv"), + ("krishiv.auth.oidc.algorithms", "ES256,RS512,ES256"), + ("krishiv.auth.oidc.clockSkew", "0s"), + ("krishiv.auth.oidc.jwksRefresh", "1h"), + ("krishiv.auth.oidc.principalClaim", "email"), + ("krishiv.auth.oidc.roleClaim", "roles"), + ("krishiv.auth.oidc.jwksUri", "https://idp.example.test/keys"), + ("krishiv.auth.oidc.allowInsecureIssuer", "false"), + ]) + .expect("valid"); + assert!(c.is_on()); + assert_eq!(c.algorithms, [Alg::Es256, Alg::Rs512]); + assert_eq!(c.jwks_refresh, Duration::from_hours(1)); + assert_eq!(c.clock_skew, Duration::ZERO); + assert_eq!( + (c.principal_claim.as_str(), c.role_claim.as_str()), + ("email", "roles") + ); + assert_eq!( + c.discovery_uri().as_deref(), + Some("https://idp.example.test/.well-known/openid-configuration") + ); + assert!(!OidcConfig::default().is_on()); + assert_eq!(OidcConfig::default().discovery_uri(), None); + assert!(OidcConfig::default().validate_values().is_ok()); + let issuer = ("krishiv.auth.oidc.issuer", ISSUER); + let audience = ("krishiv.auth.oidc.audience", "krishiv"); + for (kv, what) in [ + ( + vec![("krishiv.auth.oidc.issuer", "http://idp.test"), audience], + "http issuer", + ), + ( + vec![("krishiv.auth.oidc.issuer", "idp.test"), audience], + "no scheme", + ), + (vec![issuer], "no audience"), + (vec![audience], "no issuer"), + (vec![("krishiv.auth.oidc.issuer", " ")], "empty issuer"), + (vec![("krishiv.auth.oidc.algorithms", "HS256")], "HMAC"), + (vec![("krishiv.auth.oidc.algorithms", "none")], "none"), + (vec![("krishiv.auth.oidc.algorithms", " , ")], "empty list"), + (vec![("krishiv.auth.oidc.clockSkew", "10m")], "skew"), + (vec![("krishiv.auth.oidc.jwksRefresh", "5s")], "refresh"), + (vec![("krishiv.auth.oidc.jwksRefresh", "2d")], "unit"), + ( + vec![("krishiv.auth.oidc.allowInsecureIssuer", "yes")], + "bool", + ), + (vec![("krishiv.auth.oidc.issuers", ISSUER)], "unknown key"), + ( + vec![ + issuer, + audience, + ("krishiv.auth.oidc.jwksUri", "ftp://idp.test/keys"), + ], + "jwks scheme", + ), + ] { + assert!(parse(&kv).is_err(), "{what}"); + } + let insecure = parse(&[ + ("krishiv.auth.oidc.issuer", "http://127.0.0.1:9/"), + audience, + ("krishiv.auth.oidc.allowInsecureIssuer", "TRUE"), + ]) + .expect("allowed for tests"); + assert!(insecure.uri_allowed("http://127.0.0.1:9/keys")); + assert!(insecure.uri_allowed("HTTPS://idp.test/keys")); + assert!(!insecure.uri_allowed("file:///etc/keys")); + assert!(!c.uri_allowed("http://idp.test/keys")); + assert!(!c.uri_allowed("https:/")); + // No host, user information (it would be logged) or fragment. + for bad in [ + "https://", + "https:///keys", + "https://user:pw@idp.test/keys", + "https://idp.test/keys#x", + ] { + assert!(!c.uri_allowed(bad), "{bad}"); + } + assert!(c.uri_allowed("https://idp.test:8443/keys?tenant=a")); + assert!(c.uri_allowed("https://idp.test?x=a@b")); +} + +#[test] +fn oidc_role_lists() { + let c = parse(&[ + ("krishiv.auth.oidc.roles.admin", "platform-admins"), + ("krishiv.auth.oidc.roles.viewer", "staff, interns"), + ("krishiv.auth.oidc.roles.viewer", "staff"), + ]) + .expect_err("an issuer is required"); + assert!(c.contains("issuer"), "{c}"); + let mut c = OidcConfig::default(); + for (k, v) in [ + ("krishiv.auth.oidc.roles.admin", "platform-admins"), + ("krishiv.auth.oidc.roles.viewer", "staff, interns"), + // A key given twice keeps its last value. + ("krishiv.auth.oidc.roles.viewer", "staff"), + ] { + assert!(c.apply(k, v).expect("valid")); + } + assert_eq!(c.roles.role_of("platform-admins"), Some(super::Role::Admin)); + assert_eq!(c.roles.role_of("staff"), Some(super::Role::Viewer)); + assert_eq!(c.roles.role_of("interns"), None); + // One role per value; unknown or miscased role names are not OIDC keys. + assert!( + c.apply("krishiv.auth.oidc.roles.operator", "staff") + .is_err() + ); + assert!( + !c.apply("krishiv.auth.oidc.roles.Admin", "x") + .expect("not a key") + ); + assert!( + !c.apply("krishiv.auth.oidc.roles.owner", "x") + .expect("not a key") + ); + assert!(super::AuthConfig::from_settings([("krishiv.auth.oidc.roles.owner", "x")]).is_err()); +} + +#[test] +fn principal_names_refuse_the_certificate_format_characters() { + for c in (0..=0x10_ffff).filter_map(char::from_u32) { + assert_eq!( + jose::is_format_char(c), + super::der::is_format(c), + "U+{:04X}", + u32::from(c) + ); + } +} + +/// A build without the `oidc` feature refuses every OIDC setting at startup. +#[cfg(not(feature = "oidc"))] +#[test] +fn oidc_needs_the_feature() { + let e = super::AuthConfig::from_settings([ + ("krishiv.auth.oidc.issuer", ISSUER), + ("krishiv.auth.oidc.audience", "krishiv"), + ]) + .unwrap_err(); + assert!(e.to_string().contains("`oidc` feature"), "{e}"); + let e = super::AuthConfig::from_settings([("krishiv.auth.oidc.clockSkew", "5s")]).unwrap_err(); + assert!(e.to_string().contains("issuer"), "{e}"); +} diff --git a/crates/krishiv-session/src/auth/mod.rs b/crates/krishiv-session/src/auth/mod.rs index 313d9bf81..40c8b2308 100644 --- a/crates/krishiv-session/src/auth/mod.rs +++ b/crates/krishiv-session/src/auth/mod.rs @@ -19,12 +19,27 @@ //! - [`TlsConfig`] and [`check_listener`]: the `krishiv.server.tls.*` settings and the startup //! refusals of an address, authentication and TLS that do not fit together; `ServerTls` //! (feature `tls`) is the rustls configuration the listeners serve, reloadable. +//! - [`OidcConfig`]: OIDC access tokens (feature `oidc`): JWS compact tokens parsed by a +//! strict, fuzzed parser (`jose.rs`), signatures checked on aws-lc-rs with the algorithm pinned +//! by construction (no `none`, no HMAC), keys from a cached JWK set fetched on a thread of its +//! own, the role from the groups claim through the role lists. //! - [`ServerAuth`]: the chain (throttle, client certificate, bearer token), its failure log record (target `krishiv::auth`, never the //! credential) and its failure counters. mod config; mod der; +#[cfg_attr( + all(not(feature = "oidc"), not(test)), + expect( + dead_code, + reason = "only the router's shape test and `Alg` are used without the `oidc` verifier" + ) +)] +mod jose; mod mtls; +#[cfg(feature = "oidc")] +mod oidc; +mod oidc_config; mod roles; mod secret_ref; mod server; @@ -34,9 +49,17 @@ mod tls; mod tls_config; mod tokens; +#[cfg(test)] +mod jose_tests; #[cfg(test)] mod mtls_tests; #[cfg(test)] +#[cfg(feature = "oidc")] +mod oidc_http_tests; +#[cfg(test)] +#[cfg(feature = "oidc")] +mod oidc_tests; +#[cfg(test)] mod tests; use std::fmt; @@ -47,7 +70,9 @@ use krishiv_common::principal::Principal; pub use config::{AuthConfig, RoleLists, ThrottleConfig, TokenSpec, is_auth_key}; pub use der::{CertNames, DerError}; +pub use jose::Alg; pub use mtls::{ClientCertificates, MtlsConfig, PrincipalFrom}; +pub use oidc_config::OidcConfig; pub use roles::RolePolicy; pub use secret_ref::SecretRef; pub use server::{FailureCount, ServerAuth}; @@ -130,7 +155,7 @@ impl Surface { pub enum Method { /// A configured static token. Token, - /// An OIDC access token (a later unit). + /// An OIDC access token (a JWT the configured issuer signed). Oidc, /// A client certificate the client CA verified. Mtls, diff --git a/crates/krishiv-session/src/auth/oidc.rs b/crates/krishiv-session/src/auth/oidc.rs new file mode 100644 index 000000000..40d74c806 --- /dev/null +++ b/crates/krishiv-session/src/auth/oidc.rs @@ -0,0 +1,493 @@ +// SPDX-License-Identifier: Apache-2.0 +//! OIDC access tokens (feature `oidc`; design §3.3): the signature check on aws-lc-rs, the JWK +//! set cache and the thread that fetches it. +//! +//! - **Signatures.** RSASSA-PKCS1-v1_5 on 2048 to 8192 bit keys, and ECDSA P-256/P-384 with +//! fixed-width `r || s` (RFC 7518 §3.4). There is no `none` and no HMAC path, so an HS256 +//! token "signed" with an RSA public key cannot verify (algorithm pinning by construction). +//! - **Keys.** Only from the configured or discovered JWK set, never from the token header. +//! One thread (`krishiv-jwks`) fetches it at start and every `jwksRefresh`, with the blocking +//! reqwest client over rustls, never on a tokio worker. A token whose `kid` is not cached asks +//! for one extra fetch at most every [`ON_DEMAND_EVERY`] and waits for it; other tokens with +//! an unknown `kid` meanwhile fail at once (threat model T-A5's JWKS flood). A failed fetch, +//! or a set with no usable key, keeps the cached keys; with none cached, the answer is +//! [`AuthFailure::Unavailable`] so clients retry. One fetch (discovery and key set together) +//! ends within [`FETCH_DEADLINE`] plus one read timeout, which bounds how long a waiting +//! request waits. +//! - **One process.** The cache, the fetch limit and the failure throttle are per process: N +//! servers fetch N times. + +use std::io::Read as _; +use std::sync::mpsc; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use aws_lc_rs::signature::{self, RsaPublicKeyComponents, UnparsedPublicKey}; +use krishiv_common::error::KrishivError; +use tokio::sync::oneshot; + +use super::AuthFailure; +use super::jose::{self, Alg, ClaimRules, Identity, JoseError, Jwk, KeyMaterial}; +use super::oidc_config::OidcConfig; +use super::secret_ref::invalid; + +/// The least time between two fetches asked for by unknown key ids (design §3.3 ⚗). +pub(crate) const ON_DEMAND_EVERY: Duration = Duration::from_mins(1); +/// The time limit of one fetch (discovery and key set together). +pub(crate) const FETCH_DEADLINE: Duration = Duration::from_secs(10); +/// The time limit of one blocking read of a response body. +const READ_TIMEOUT: Duration = Duration::from_secs(5); + +/// Where the JWK set comes from. The HTTP source in production; tests supply their own. +pub(crate) trait KeySource: Send + 'static { + /// The JWK set document's bytes. The error text is logged (it never holds a token). + fn fetch(&mut self) -> Result, String>; +} + +/// Whether `signature` over `input` verifies with `key` for `alg`. The key must fit `alg` +/// ([`Jwk::fits`]); a key that does not never verifies. +#[must_use] +pub(crate) fn verify_signature(alg: Alg, key: &Jwk, input: &[u8], signature: &[u8]) -> bool { + if !key.fits(alg) { + return false; + } + match (&key.material, alg) { + (KeyMaterial::Rsa { n, e }, Alg::Rs256 | Alg::Rs384 | Alg::Rs512) => { + let params = match alg { + Alg::Rs256 => &signature::RSA_PKCS1_2048_8192_SHA256, + Alg::Rs384 => &signature::RSA_PKCS1_2048_8192_SHA384, + _ => &signature::RSA_PKCS1_2048_8192_SHA512, + }; + RsaPublicKeyComponents { n, e } + .verify(params, input, signature) + .is_ok() + } + (KeyMaterial::Ec { point, .. }, Alg::Es256) => { + UnparsedPublicKey::new(&signature::ECDSA_P256_SHA256_FIXED, point) + .verify(input, signature) + .is_ok() + } + (KeyMaterial::Ec { point, .. }, Alg::Es384) => { + UnparsedPublicKey::new(&signature::ECDSA_P384_SHA384_FIXED, point) + .verify(input, signature) + .is_ok() + } + _ => false, + } +} + +/// The cached keys and when they may be fetched again on demand. +#[derive(Debug, Default)] +struct CacheState { + keys: Arc<[Jwk]>, + /// The last fetch asked for by a request. + last_on_demand: Option, +} + +/// The answer of [`JwksCache::lookup`]. +#[derive(Debug)] +pub(crate) enum Lookup { + /// The keys that may verify the token: the cached set and the indexes of the fitting keys. + Keys(Arc<[Jwk]>, Vec), + /// A token without `kid` that several cached keys fit: refused rather than tried against + /// each. + Ambiguous, + /// No cached key fits, and this request may wait for one fetch. + Refresh, + /// No cached key fits, and a fetch was asked for too recently. + Unknown, + /// No key at all is cached, and a fetch was asked for too recently. + NoKeys, +} + +/// The JWK set cache, shared by requests and the fetch thread. +#[derive(Debug, Default)] +pub(crate) struct JwksCache { + state: Mutex, +} + +impl JwksCache { + fn state(&self) -> std::sync::MutexGuard<'_, CacheState> { + // The state is replaced whole under the lock, so a poisoned one is still consistent. + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// The keys that may verify a token with `kid` and `alg`, at `now`. A token without a `kid` + /// may use the one fitting key, if exactly one fits. Asking for a fetch is rate-limited + /// here: the request that gets [`Lookup::Refresh`] has taken the slot. + pub(crate) fn lookup(&self, kid: Option<&str>, alg: Alg, now: Instant) -> Lookup { + let keys = Arc::clone(&self.state().keys); + let fitting: Vec = keys + .iter() + .enumerate() + .filter(|(_, k)| k.fits(alg) && (kid.is_none() || k.kid.as_deref() == kid)) + .map(|(i, _)| i) + .collect(); + if kid.is_none() && fitting.len() > 1 { + return Lookup::Ambiguous; + } + if !fitting.is_empty() { + return Lookup::Keys(keys, fitting); + } + let mut state = self.state(); + let may = state + .last_on_demand + .is_none_or(|t| now.saturating_duration_since(t) >= ON_DEMAND_EVERY); + if may { + state.last_on_demand = Some(now); + Lookup::Refresh + } else if state.keys.is_empty() { + Lookup::NoKeys + } else { + Lookup::Unknown + } + } + + /// Replaces the keys with the set in `document`. A document that does not parse, or holds + /// no usable key while keys are cached, keeps the old keys (an empty set from a provider in + /// trouble must not lock every user out). + /// + /// # Errors + /// The parse error, or `no usable key`. + pub(crate) fn install(&self, document: &[u8]) -> Result { + let keys = jose::parse_jwks(document)?; + let n = keys.len(); + let mut state = self.state(); + if n == 0 && !state.keys.is_empty() { + return Err(JoseError::Invalid("no usable key")); + } + state.keys = keys.into(); + Ok(n) + } + + /// How many keys are cached (tests). + #[cfg(test)] + pub(crate) fn key_count(&self) -> usize { + self.state().keys.len() + } +} + +/// A request to the fetch thread; `Some` wants to know when the fetch is done. +type FetchRequest = Option>; + +/// The OIDC verifier of one server. +#[derive(Debug)] +pub(crate) struct Oidc { + issuer: String, + audience: String, + algorithms: Vec, + principal_claim: String, + role_claim: String, + skew_secs: u64, + jwks: Arc, + /// Requests to the fetch thread; dropping it stops the thread. + fetch: mpsc::Sender, +} + +/// The outcome of one verification. +#[derive(Debug)] +pub(crate) enum Verified { + /// A valid token. + Ok(Identity), + /// Refused. + Failed(AuthFailure), + /// No cached key fits; wait for [`Oidc::refresh`], then verify again without a refresh. + Refresh, +} + +impl Oidc { + /// Starts the verifier and its fetch thread over HTTP. + /// + /// # Errors + /// `KRISHIV_INVALID_CONFIG_VALUE` when OIDC is not fully configured, or the thread cannot + /// start. + pub(crate) fn start(config: &OidcConfig) -> Result { + let source = HttpKeySource::new(config); + Self::with_source(config, Box::new(source)) + } + + /// Starts the verifier with `source` (tests use an in-memory one). + /// + /// # Errors + /// As [`Oidc::start`]. + pub(crate) fn with_source( + config: &OidcConfig, + mut source: Box, + ) -> Result { + let (Some(issuer), Some(audience)) = (&config.issuer, &config.audience) else { + return Err(invalid( + "krishiv.auth.oidc.issuer", + "", + "OIDC needs an issuer and an audience", + )); + }; + let jwks = Arc::new(JwksCache::default()); + let (tx, rx) = mpsc::channel::(); + let cache = Arc::clone(&jwks); + let every = config.jwks_refresh; + std::thread::Builder::new() + .name("krishiv-jwks".to_owned()) + .spawn(move || fetch_loop(source.as_mut(), &cache, &rx, every)) + .map_err(|e| { + invalid( + "krishiv.auth.oidc.issuer", + issuer, + &format!("cannot start the JWK set thread: {e}"), + ) + })?; + Ok(Self { + issuer: issuer.clone(), + audience: audience.clone(), + algorithms: config.algorithms.clone(), + principal_claim: config.principal_claim.clone(), + role_claim: config.role_claim.clone(), + skew_secs: config.clock_skew.as_secs(), + jwks, + fetch: tx, + }) + } + + /// Verifies `token` at `now` (monotonic, for the fetch limit) and `unix_now` (seconds since + /// the epoch, for `exp`/`nbf`). With `may_refresh`, a token whose key is not cached gets + /// [`Verified::Refresh`] when a fetch is allowed; without, that fetch is still started and + /// the token is refused as [`AuthFailure::Unavailable`]. + pub(crate) fn verify( + &self, + token: &str, + now: Instant, + unix_now: u64, + may_refresh: bool, + ) -> Verified { + let jws = match jose::parse_jws(token) { + Ok(jws) => jws, + Err(e) => return refused(e), + }; + let alg = jws.header.alg; + if !self.algorithms.contains(&alg) { + return refused(JoseError::Invalid("algorithm not allowed")); + } + let (keys, fitting) = match self.jwks.lookup(jws.header.kid.as_deref(), alg, now) { + Lookup::Keys(keys, fitting) => (keys, fitting), + Lookup::Refresh if may_refresh => return Verified::Refresh, + Lookup::Refresh => { + self.request(None); + return Verified::Failed(AuthFailure::Unavailable); + } + Lookup::NoKeys => return Verified::Failed(AuthFailure::Unavailable), + Lookup::Unknown => return refused(JoseError::Invalid("unknown key")), + Lookup::Ambiguous => return refused(JoseError::Invalid("no kid and several keys")), + }; + let signed = fitting + .iter() + .filter_map(|i| keys.get(*i)) + .any(|k| verify_signature(alg, k, jws.signing_input, &jws.signature)); + if !signed { + return refused(JoseError::Invalid("signature")); + } + let rules = ClaimRules { + issuer: &self.issuer, + audience: &self.audience, + principal_claim: &self.principal_claim, + role_claim: &self.role_claim, + skew_secs: self.skew_secs, + }; + match jose::check_claims(&jws.claims, &rules, unix_now) { + Ok(identity) => Verified::Ok(identity), + Err(e) => refused(e), + } + } + + /// Asks the fetch thread for a fetch now; the receiver completes when it is done (or + /// errors when the thread has stopped). + pub(crate) fn refresh(&self) -> oneshot::Receiver<()> { + let (tx, rx) = oneshot::channel(); + self.request(Some(tx)); + rx + } + + fn request(&self, done: FetchRequest) { + if self.fetch.send(done).is_err() { + // The receiver of a waiting request then errors at once, so nobody hangs. + tracing::error!(target: "krishiv::auth", "the JWK set thread has stopped"); + } + } + + /// The key cache (tests). + #[cfg(test)] + pub(crate) fn jwks(&self) -> &JwksCache { + &self.jwks + } +} + +/// A refusal, with its fixed reason logged at debug level (never token content). +fn refused(e: JoseError) -> Verified { + tracing::debug!(target: "krishiv::auth", reason = %e, "OIDC token refused"); + Verified::Failed(match e { + JoseError::Expired => AuthFailure::Expired, + JoseError::Invalid(_) => AuthFailure::Invalid, + }) +} + +/// Seconds since the Unix epoch now (0 before 1970, which no token is valid at). +#[must_use] +pub(crate) fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) +} + +/// The fetch thread: once at start, then every `every` and on request, until the verifier is +/// dropped. Requests that arrive during a fetch are answered by the next one. +fn fetch_loop( + source: &mut dyn KeySource, + cache: &JwksCache, + rx: &mpsc::Receiver, + every: Duration, +) { + let mut waiters: Vec> = Vec::new(); + loop { + match source.fetch() { + Ok(bytes) => match cache.install(&bytes) { + Ok(n) => tracing::debug!(target: "krishiv::auth", keys = n, "JWK set fetched"), + Err(e) => tracing::error!( + target: "krishiv::auth", + error = %e, + "the JWK set does not parse; keeping the cached keys" + ), + }, + Err(e) => tracing::error!( + target: "krishiv::auth", + error = %e, + "cannot fetch the JWK set; keeping the cached keys" + ), + } + for w in waiters.drain(..) { + // A waiter that gave up has dropped its receiver; nothing to tell it. + w.send(()).ok(); + } + match rx.recv_timeout(every) { + Ok(request) => { + waiters.extend(request); + // Requests queued meanwhile share this fetch. + while let Ok(request) = rx.try_recv() { + waiters.extend(request); + } + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => return, + } + } +} + +/// The JWK set over HTTPS: `jwksUri`, or the one the issuer's discovery document names. +pub(crate) struct HttpKeySource { + issuer: String, + configured: Option, + discovery: Option, + discovered: Option, + config: OidcConfig, + /// Built on the fetch thread at the first fetch (a blocking client must not be built or + /// dropped inside a tokio runtime). + client: Option, +} + +impl HttpKeySource { + pub(crate) fn new(config: &OidcConfig) -> Self { + Self { + issuer: config.issuer.clone().unwrap_or_default(), + configured: config.jwks_uri.clone(), + discovery: config.discovery_uri(), + discovered: None, + config: config.clone(), + client: None, + } + } + + /// GETs `uri` before `deadline`: no redirects, at most [`jose::MAX_DOCUMENT_BYTES`]. + fn get(&mut self, uri: &str, deadline: Instant) -> Result, String> { + if !self.config.uri_allowed(uri) { + return Err(format!("refusing the location {uri}")); + } + let client = match &self.client { + Some(c) => c, + None => { + let c = reqwest::blocking::Client::builder() + // Applied to connecting, to the headers and to each read of the body. + .timeout(READ_TIMEOUT) + .connect_timeout(READ_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| format!("cannot build the HTTP client: {e}"))?; + self.client.insert(c) + } + }; + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(format!("GET {uri}: out of time")); + } + let mut resp = client + .get(uri) + .header("accept", "application/json") + // The whole request, body included, ends by the deadline. + .timeout(left) + .send() + .map_err(|e| format!("GET {uri}: {e}"))?; + if !resp.status().is_success() { + return Err(format!("GET {uri}: HTTP {}", resp.status())); + } + let too_large = || format!("GET {uri}: the document is larger than 1 MiB"); + if resp + .content_length() + .is_some_and(|n| usize::try_from(n).map_or(true, |n| n > jose::MAX_DOCUMENT_BYTES)) + { + return Err(too_large()); + } + let mut body = Vec::new(); + let mut buf = [0u8; 16 * 1024]; + loop { + if Instant::now() >= deadline { + return Err(format!("GET {uri}: out of time")); + } + let n = resp.read(&mut buf).map_err(|e| { + if Instant::now() >= deadline { + format!("GET {uri}: out of time") + } else { + format!("GET {uri}: {e}") + } + })?; + if n == 0 { + return Ok(body); + } + if body.len() + n > jose::MAX_DOCUMENT_BYTES { + return Err(too_large()); + } + body.extend_from_slice(buf.get(..n).unwrap_or_default()); + } + } +} + +impl KeySource for HttpKeySource { + fn fetch(&mut self) -> Result, String> { + let deadline = Instant::now() + FETCH_DEADLINE; + if let Some(uri) = self.configured.clone() { + return self.get(&uri, deadline); + } + let uri = match self.discovered.clone() { + Some(uri) => uri, + None => { + let discovery = self.discovery.clone().unwrap_or_default(); + let doc = self.get(&discovery, deadline)?; + let uri = jose::discovered_jwks_uri(&doc, &self.issuer) + .map_err(|e| format!("{discovery}: {e}"))?; + tracing::info!(target: "krishiv::auth", location = %uri, "JWK set location discovered"); + self.discovered.insert(uri).clone() + } + }; + let result = self.get(&uri, deadline); + if result.is_err() { + // The provider may have moved its keys: discover again next time. + self.discovered = None; + } + result + } +} diff --git a/crates/krishiv-session/src/auth/oidc_config.rs b/crates/krishiv-session/src/auth/oidc_config.rs new file mode 100644 index 000000000..67d93376e --- /dev/null +++ b/crates/krishiv-session/src/auth/oidc_config.rs @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: Apache-2.0 +//! The `krishiv.auth.oidc.*` settings (design §3.3; ADR 0046 §6). +//! +//! ```text +//! krishiv.auth.oidc.issuer = https://idp.example.com/ (exact match on `iss`; turns OIDC on) +//! krishiv.auth.oidc.audience = krishiv (must be in `aud`; required) +//! krishiv.auth.oidc.jwksUri = https://.. (default: discovered from the issuer) +//! krishiv.auth.oidc.algorithms = RS256 (allowlist of RS256, RS384, RS512, ES256, ES384) +//! krishiv.auth.oidc.principalClaim = sub +//! krishiv.auth.oidc.roleClaim = groups (its values are matched against the lists below) +//! krishiv.auth.oidc.roles.{viewer,operator,admin} = a, b (role-claim values by role) +//! krishiv.auth.oidc.clockSkew = 60s (0s to 5m) +//! krishiv.auth.oidc.jwksRefresh = 10m (1m to 24h) +//! krishiv.auth.oidc.allowInsecureIssuer = false (http:// issuer and JWKS; tests only) +//! ``` +//! +//! The OIDC role lists are separate from `krishiv.auth.roles.*`, which name certificate +//! principals: a group value and a certificate name never share a namespace, so an identity +//! provider group cannot be named like a certificate principal to take its role, nor the reverse. +//! Lists are comma separated, so a value that contains a comma (an LDAP distinguished name such +//! as `CN=admins,OU=..`) cannot be listed: have the provider emit plain group names. +//! +//! A build without the `oidc` feature refuses every one of these keys, so a configuration that +//! asks for OIDC never starts a server that cannot check its tokens. + +use std::time::Duration; + +use krishiv_common::error::KrishivError; + +use super::config::{RoleLists, parse_duration}; +use super::jose::Alg; +use super::secret_ref::invalid; + +const PREFIX: &str = "krishiv.auth.oidc."; + +/// The OIDC settings. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct OidcConfig { + /// The issuer; OIDC is on when set. + pub issuer: Option, + /// The audience every token must name. + pub audience: Option, + /// The JWK set location; discovered from the issuer when unset. + pub jwks_uri: Option, + /// The algorithms accepted. + pub algorithms: Vec, + /// The claim naming the principal. + pub principal_claim: String, + /// The claim holding the groups the role lists match. + pub role_claim: String, + /// `krishiv.auth.oidc.roles.*`: role-claim values by role. + pub roles: RoleLists, + /// Allowed clock difference for `exp` and `nbf`. + pub clock_skew: Duration, + /// How often the JWK set is fetched again. + pub jwks_refresh: Duration, + /// `http://` issuer and JWK set locations are allowed (tests only). + pub allow_insecure_issuer: bool, +} + +impl Default for OidcConfig { + fn default() -> Self { + Self { + issuer: None, + audience: None, + jwks_uri: None, + algorithms: vec![Alg::Rs256], + principal_claim: "sub".to_owned(), + role_claim: "groups".to_owned(), + roles: RoleLists::default(), + clock_skew: Duration::from_mins(1), + jwks_refresh: Duration::from_mins(10), + allow_insecure_issuer: false, + } + } +} + +impl OidcConfig { + /// Applies `key = raw` if it is an OIDC setting; returns whether it was. + pub(crate) fn apply(&mut self, key: &str, raw: &str) -> Result { + let Some(rest) = key.strip_prefix(PREFIX) else { + return Ok(false); + }; + let text = || -> Result { + let v = raw.trim(); + if v.is_empty() { + return Err(invalid(key, raw, "must not be empty")); + } + Ok(v.to_owned()) + }; + match rest { + "issuer" => self.issuer = Some(text()?), + "audience" => self.audience = Some(text()?), + "jwksUri" => self.jwks_uri = Some(text()?), + "algorithms" => { + let mut algs = Vec::new(); + for name in raw.split(',').map(str::trim).filter(|s| !s.is_empty()) { + let alg = Alg::parse(name).ok_or_else(|| { + invalid( + key, + raw, + "expected a list of RS256, RS384, RS512, ES256, ES384", + ) + })?; + if !algs.contains(&alg) { + algs.push(alg); + } + } + if algs.is_empty() { + return Err(invalid(key, raw, "at least one algorithm")); + } + self.algorithms = algs; + } + "principalClaim" => self.principal_claim = text()?, + "roleClaim" => self.role_claim = text()?, + "clockSkew" => { + self.clock_skew = parse_duration(key, raw)?; + if self.clock_skew > Duration::from_mins(5) { + return Err(invalid(key, raw, "must be between 0s and 5m")); + } + } + "jwksRefresh" => { + self.jwks_refresh = parse_duration(key, raw)?; + if !(Duration::from_mins(1)..=Duration::from_hours(24)).contains(&self.jwks_refresh) + { + return Err(invalid(key, raw, "must be between 1m and 24h")); + } + } + _ if rest.starts_with("roles.") => { + let name = rest.strip_prefix("roles.").unwrap_or_default(); + let Some(role) = super::Role::parse(name).filter(|r| r.as_str() == name) else { + return Ok(false); + }; + self.roles.set(key, role, raw)?; + } + "allowInsecureIssuer" => { + self.allow_insecure_issuer = match raw.trim().to_ascii_lowercase().as_str() { + "true" => true, + "false" => false, + _ => return Err(invalid(key, raw, "expected true or false")), + }; + } + _ => return Ok(false), + } + Ok(true) + } + + /// Whether OIDC is configured. + #[must_use] + pub fn is_on(&self) -> bool { + self.issuer.is_some() + } + + /// The location the JWK set is fetched from first: `jwksUri`, else the discovery document + /// `/.well-known/openid-configuration`, whose `jwks_uri` is then used. + #[must_use] + pub fn discovery_uri(&self) -> Option { + self.issuer.as_deref().map(|i| { + format!( + "{}/.well-known/openid-configuration", + i.trim_end_matches('/') + ) + }) + } + + /// Whether `uri` may be fetched: `https://`, or `http://` with `allowInsecureIssuer`; a + /// non-empty host; no user information (it would be logged) and no fragment. + #[must_use] + pub fn uri_allowed(&self, uri: &str) -> bool { + let lower = uri.to_ascii_lowercase(); + let rest = if let Some(rest) = lower.strip_prefix("https://") { + rest + } else if let Some(rest) = lower.strip_prefix("http://") + && self.allow_insecure_issuer + { + rest + } else { + return false; + }; + let authority = rest.split(['/', '?']).next().unwrap_or_default(); + !authority.is_empty() && !authority.contains('@') && !uri.contains('#') + } + + /// Startup checks: [`OidcConfig::validate_values`], and a build with the `oidc` feature + /// when any OIDC key is given. + pub(crate) fn validate(&self) -> Result<(), KrishivError> { + self.validate_values()?; + if !cfg!(feature = "oidc") && *self != Self::default() { + return Err(invalid( + &format!("{PREFIX}issuer"), + self.issuer.as_deref().unwrap_or_default(), + "this build has no OIDC (the `oidc` feature of krishiv-session is off)", + )); + } + Ok(()) + } + + /// The checks of the values themselves: an issuer with any other OIDC key, an audience + /// with the issuer, and `https://` locations unless `allowInsecureIssuer` (design §3.5's + /// fourth refusal). + pub(crate) fn validate_values(&self) -> Result<(), KrishivError> { + let Some(issuer) = &self.issuer else { + if *self != Self::default() { + return Err(invalid( + &format!("{PREFIX}issuer"), + "", + "required when any krishiv.auth.oidc.* setting is given", + )); + } + return Ok(()); + }; + if self.audience.is_none() { + return Err(invalid( + &format!("{PREFIX}audience"), + "", + "required with krishiv.auth.oidc.issuer", + )); + } + if !self.uri_allowed(issuer) { + return Err(invalid( + &format!("{PREFIX}issuer"), + issuer, + "must be an https:// URL (http:// only with krishiv.auth.oidc.allowInsecureIssuer, for tests)", + )); + } + if let Some(uri) = &self.jwks_uri + && !self.uri_allowed(uri) + { + return Err(invalid( + &format!("{PREFIX}jwksUri"), + uri, + "must be an https:// URL (http:// only with krishiv.auth.oidc.allowInsecureIssuer, for tests)", + )); + } + Ok(()) + } +} diff --git a/crates/krishiv-session/src/auth/oidc_http_tests.rs b/crates/krishiv-session/src/auth/oidc_http_tests.rs new file mode 100644 index 000000000..ed49b435a --- /dev/null +++ b/crates/krishiv-session/src/auth/oidc_http_tests.rs @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: Apache-2.0 +//! The HTTP JWK set source against a loopback HTTP server (`allowInsecureIssuer`): discovery, +//! no redirects, the size cap, location and issuer checks, discovering again after a failure, +//! and the fetch deadline. + +use std::io::{BufRead as _, BufReader, Write as _}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, Instant}; + +use super::jose::MAX_DOCUMENT_BYTES; +use super::oidc::{FETCH_DEADLINE, HttpKeySource, KeySource}; +use super::oidc_config::OidcConfig; + +const DISCOVERY: &str = "/.well-known/openid-configuration"; +const KEYS: &str = r#"{"keys":[]}"#; + +/// One canned answer: status line, extra headers, body; `slow` trickles bytes forever. +#[derive(Clone)] +struct Reply { + status: &'static str, + headers: String, + body: Vec, + slow: bool, +} + +fn ok(body: impl Into>) -> Reply { + let body = body.into(); + Reply { + status: "200 OK", + headers: format!("content-length: {}\r\n", body.len()), + body, + slow: false, + } +} + +fn status(status: &'static str, headers: &str) -> Reply { + Reply { + status, + headers: format!("{headers}content-length: 0\r\n"), + body: Vec::new(), + slow: false, + } +} + +fn discovery(issuer: &str, jwks_uri: &str) -> Reply { + ok(format!( + r#"{{"issuer":"{issuer}","jwks_uri":"{jwks_uri}"}}"# + )) +} + +type Routes = Vec<(&'static str, Reply)>; + +/// A loopback server answering by path; records every path asked for. +struct Server { + base: String, + hits: Arc>>, +} + +impl Server { + /// Starts a server whose routes are built from its own base address (`http://127.0.0.1:N`). + fn start(routes: impl FnOnce(&str) -> Routes) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let routes = routes(&base); + let hits = Arc::new(Mutex::new(Vec::new())); + let seen = Arc::clone(&hits); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { return }; + let routes = routes.clone(); + let seen = Arc::clone(&seen); + std::thread::spawn(move || serve(stream, &routes, &seen)); + } + }); + Self { base, hits } + } + + fn issuer(&self) -> String { + format!("{}/", self.base) + } + + fn hits(&self) -> Vec { + self.hits + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } +} + +fn serve(stream: TcpStream, routes: &Routes, seen: &Mutex>) { + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let path = line.split(' ').nth(1).unwrap_or_default().to_owned(); + loop { + let mut header = String::new(); + if reader.read_line(&mut header).unwrap_or(0) == 0 || header == "\r\n" { + break; + } + } + seen.lock() + .unwrap_or_else(PoisonError::into_inner) + .push(path.clone()); + let reply = routes + .iter() + .find(|(p, _)| *p == path) + .map_or_else(|| status("404 Not Found", ""), |(_, r)| r.clone()); + let mut out = stream; + let head = format!( + "HTTP/1.1 {}\r\n{}connection: close\r\n\r\n", + reply.status, reply.headers + ); + if out.write_all(head.as_bytes()).is_err() { + return; + } + if reply.slow { + // One byte a second, until the client hangs up. + while out.write_all(b" ").and_then(|()| out.flush()).is_ok() { + std::thread::sleep(Duration::from_secs(1)); + } + return; + } + out.write_all(&reply.body).ok(); +} + +fn config(issuer: &str, jwks_uri: Option<&str>) -> OidcConfig { + let mut c = OidcConfig::default(); + let mut set = |k: &str, v: &str| assert!(c.apply(k, v).unwrap(), "{k}"); + set("krishiv.auth.oidc.issuer", issuer); + set("krishiv.auth.oidc.audience", "krishiv"); + set("krishiv.auth.oidc.allowInsecureIssuer", "true"); + if let Some(uri) = jwks_uri { + set("krishiv.auth.oidc.jwksUri", uri); + } + c.validate_values().unwrap(); + c +} + +/// Runs one fetch on a thread of its own (a blocking client must not run inside a runtime). +fn fetch(source: HttpKeySource) -> (HttpKeySource, Result, String>) { + std::thread::spawn(move || { + let mut source = source; + let r = source.fetch(); + (source, r) + }) + .join() + .unwrap() +} + +/// A source over `server` with `jwksUri` = `path` on it. +fn fixed(server: &Server, path: &str) -> HttpKeySource { + let uri = format!("{}{path}", server.base); + HttpKeySource::new(&config(&server.issuer(), Some(&uri))) +} + +#[test] +fn discovery_then_key_set() { + let server = Server::start(|base| { + vec![ + ( + DISCOVERY, + discovery(&format!("{base}/"), &format!("{base}/keys")), + ), + ("/keys", ok(KEYS)), + ] + }); + let (source, body) = fetch(HttpKeySource::new(&config(&server.issuer(), None))); + assert_eq!(body.unwrap(), KEYS.as_bytes()); + assert_eq!(server.hits(), [DISCOVERY, "/keys"]); + // The location is kept: the next fetch asks for the keys only. + let (_, body) = fetch(source); + assert!(body.is_ok()); + assert_eq!(server.hits(), [DISCOVERY, "/keys", "/keys"]); +} + +#[test] +fn the_discovery_issuer_must_match() { + let server = Server::start(|base| { + vec![ + ( + DISCOVERY, + discovery("https://idp.example.test/", &format!("{base}/keys")), + ), + ("/keys", ok(KEYS)), + ] + }); + let (_, body) = fetch(HttpKeySource::new(&config(&server.issuer(), None))); + assert!(body.unwrap_err().contains("discovery issuer")); + assert_eq!(server.hits(), [DISCOVERY]); +} + +#[test] +fn redirects_are_not_followed() { + let server = Server::start(|_| { + vec![ + ("/moved", status("302 Found", "location: /keys\r\n")), + ("/keys", ok(KEYS)), + ] + }); + let (_, body) = fetch(fixed(&server, "/moved")); + assert!(body.unwrap_err().contains("302")); + assert_eq!(server.hits(), ["/moved"]); +} + +#[test] +fn documents_over_the_cap_are_refused() { + let big = vec![b' '; MAX_DOCUMENT_BYTES + 1]; + let server = Server::start(|_| { + vec![ + ("/declared", ok(big.clone())), + ( + "/undeclared", + Reply { + headers: String::new(), + ..ok(big) + }, + ), + ] + }); + for path in ["/declared", "/undeclared"] { + let (_, body) = fetch(fixed(&server, path)); + assert!(body.unwrap_err().contains("larger than 1 MiB"), "{path}"); + } +} + +#[test] +fn discovered_locations_are_checked() { + for jwks in [ + "ftp://127.0.0.1/keys", + "http://user@127.0.0.1/keys", + "http:///keys", + ] { + let server = Server::start(|base| vec![(DISCOVERY, discovery(&format!("{base}/"), jwks))]); + let (_, body) = fetch(HttpKeySource::new(&config(&server.issuer(), None))); + assert!( + body.unwrap_err().contains("refusing the location"), + "{jwks}" + ); + assert_eq!(server.hits(), [DISCOVERY], "{jwks}"); + } + // http:// without the test flag is refused before any request. + let server = Server::start(|_| vec![("/keys", ok(KEYS))]); + let mut strict = config(&server.issuer(), Some(&format!("{}/keys", server.base))); + strict.allow_insecure_issuer = false; + let (_, body) = fetch(HttpKeySource::new(&strict)); + assert!(body.unwrap_err().contains("refusing the location")); + assert!(server.hits().is_empty()); +} + +#[test] +fn a_failed_key_fetch_discovers_again() { + let server = Server::start(|base| { + vec![ + ( + DISCOVERY, + discovery(&format!("{base}/"), &format!("{base}/keys")), + ), + ("/keys", status("500 Internal Server Error", "")), + ] + }); + let (source, body) = fetch(HttpKeySource::new(&config(&server.issuer(), None))); + assert!(body.unwrap_err().contains("500")); + let (_, body) = fetch(source); + assert!(body.is_err()); + assert_eq!(server.hits(), [DISCOVERY, "/keys", DISCOVERY, "/keys"]); +} + +#[test] +fn a_slow_body_ends_at_the_deadline() { + let server = Server::start(|_| { + vec![( + "/keys", + Reply { + status: "200 OK", + headers: String::new(), + body: Vec::new(), + slow: true, + }, + )] + }); + let start = Instant::now(); + let (_, body) = fetch(fixed(&server, "/keys")); + let took = start.elapsed(); + let e = body.unwrap_err(); + assert!(e.contains("out of time"), "{e}"); + assert!( + took >= FETCH_DEADLINE - Duration::from_secs(1) + && took < FETCH_DEADLINE + Duration::from_secs(4), + "{took:?}" + ); +} diff --git a/crates/krishiv-session/src/auth/oidc_tests.rs b/crates/krishiv-session/src/auth/oidc_tests.rs new file mode 100644 index 000000000..7953d64ae --- /dev/null +++ b/crates/krishiv-session/src/auth/oidc_tests.rs @@ -0,0 +1,661 @@ +// SPDX-License-Identifier: Apache-2.0 +//! OIDC tests (design §4: `oidc_basic_validation`, `oidc_algorithm_pinning`, +//! `oidc_unknown_kid_refresh_limit`, `oidc_idp_down_uses_cache`). Keys are generated per run +//! with aws-lc-rs and served from an in-memory JWK set source. + +use std::net::SocketAddr; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use aws_lc_rs::rand::SystemRandom; +use aws_lc_rs::signature::{ + ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair, RSA_PKCS1_SHA256, RsaKeyPair, +}; +use futures::executor::block_on; +use serde_json::{Value, json}; + +use super::jose::{self, Alg}; +use super::oidc::{JwksCache, KeySource, Lookup, ON_DEMAND_EVERY, unix_now}; +use super::{ + AuthConfig, AuthFailure, Authenticated, Authenticator, Credentials, Method, Role, ServerAuth, + Surface, +}; + +const ISSUER: &str = "https://idp.example.test/"; +const AUDIENCE: &str = "krishiv"; + +fn rsa() -> &'static RsaKeyPair { + static KEY: OnceLock = OnceLock::new(); + KEY.get_or_init(|| { + RsaKeyPair::generate(aws_lc_rs::rsa::KeySize::Rsa2048).expect("generate an RSA key") + }) +} + +fn rsa_other() -> &'static RsaKeyPair { + static KEY: OnceLock = OnceLock::new(); + KEY.get_or_init(|| { + RsaKeyPair::generate(aws_lc_rs::rsa::KeySize::Rsa2048).expect("generate an RSA key") + }) +} + +fn ec() -> &'static EcdsaKeyPair { + static KEY: OnceLock = OnceLock::new(); + KEY.get_or_init(|| { + EcdsaKeyPair::generate(&ECDSA_P256_SHA256_FIXED_SIGNING).expect("generate a P-256 key") + }) +} + +fn b64(bytes: &[u8]) -> String { + const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::new(); + for chunk in bytes.chunks(3) { + let mut acc = 0u32; + for (i, b) in chunk.iter().enumerate() { + acc |= u32::from(*b) << (16 - 8 * i); + } + for i in 0..=chunk.len() { + out.push(char::from(A[((acc >> (18 - 6 * i)) & 63) as usize])); + } + } + out +} + +/// `(n, e)` of an RSA public key's DER (`RSAPublicKey ::= SEQUENCE { n INTEGER, e INTEGER }`). +fn rsa_components(der: &[u8]) -> (Vec, Vec) { + fn tlv(d: &[u8]) -> (&[u8], &[u8]) { + let (len, head) = match d[1] { + l if l < 0x80 => (usize::from(l), 2), + 0x81 => (usize::from(d[2]), 3), + 0x82 => (usize::from(d[2]) << 8 | usize::from(d[3]), 4), + _ => panic!("unexpected length"), + }; + (&d[head..head + len], &d[head + len..]) + } + let (seq, _) = tlv(der); + let (n, rest) = tlv(seq); + let (e, _) = tlv(rest); + let strip = |v: &[u8]| { + v.iter() + .skip_while(|b| **b == 0) + .copied() + .collect::>() + }; + (strip(n), strip(e)) +} + +fn rsa_jwk(key: &RsaKeyPair, kid: &str) -> Value { + let (n, e) = rsa_components(key.public_key().as_ref()); + json!({"kty": "RSA", "kid": kid, "use": "sig", "alg": "RS256", "n": b64(&n), "e": b64(&e)}) +} + +fn ec_jwk(key: &EcdsaKeyPair, kid: &str) -> Value { + let point = key.public_key().as_ref(); + json!({"kty": "EC", "kid": kid, "crv": "P-256", "x": b64(&point[1..33]), "y": b64(&point[33..65])}) +} + +fn jwks(keys: &[Value]) -> Vec { + serde_json::to_vec(&json!({ "keys": keys })).expect("serialize") +} + +/// A token with `header` and `claims`, signed by `sign` over the signing input. +fn token(header: &Value, claims: &Value, sign: impl Fn(&[u8]) -> Vec) -> String { + let input = format!( + "{}.{}", + b64(&serde_json::to_vec(header).expect("header")), + b64(&serde_json::to_vec(claims).expect("claims")) + ); + let sig = sign(input.as_bytes()); + format!("{input}.{}", b64(&sig)) +} + +fn rs256(key: &'static RsaKeyPair) -> impl Fn(&[u8]) -> Vec { + move |msg| { + let mut sig = vec![0; key.public_modulus_len()]; + key.sign(&RSA_PKCS1_SHA256, &SystemRandom::new(), msg, &mut sig) + .expect("sign"); + sig + } +} + +fn es256(msg: &[u8]) -> Vec { + ec().sign(&SystemRandom::new(), msg) + .expect("sign") + .as_ref() + .to_vec() +} + +fn claims() -> Value { + let now = unix_now(); + json!({ + "iss": ISSUER, "aud": [AUDIENCE, "other"], "sub": "alice", + "groups": ["analysts", "staff"], "exp": now + 300, "nbf": now - 10, + }) +} + +fn rs_header(kid: &str) -> Value { + json!({"alg": "RS256", "typ": "JWT", "kid": kid}) +} + +/// The answers of a [`Source`], in order. +type Answers = Arc, String>>>>; + +/// An in-memory JWK set source: each fetch takes the next answer (the last repeats). +#[derive(Clone)] +struct Source { + answers: Answers, + fetches: Arc, +} + +impl Source { + fn new(answers: Vec, String>>) -> Self { + Self { + answers: Arc::new(Mutex::new(answers)), + fetches: Arc::new(AtomicUsize::new(0)), + } + } + + fn fetches(&self) -> usize { + self.fetches.load(Ordering::SeqCst) + } +} + +impl KeySource for Source { + fn fetch(&mut self) -> Result, String> { + self.fetches.fetch_add(1, Ordering::SeqCst); + let mut answers = self + .answers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if answers.len() > 1 { + answers.remove(0) + } else { + answers[0].clone() + } + } +} + +fn settings(extra: &[(&str, &str)]) -> AuthConfig { + let mut all = vec![ + ("krishiv.auth.oidc.issuer", ISSUER), + ("krishiv.auth.oidc.audience", AUDIENCE), + ("krishiv.auth.oidc.algorithms", "RS256, ES256"), + ("krishiv.auth.oidc.roles.operator", "analysts"), + ("krishiv.auth.oidc.roles.viewer", "staff"), + ]; + all.extend_from_slice(extra); + AuthConfig::from_settings(all).expect("settings") +} + +fn server(source: &Source, extra: &[(&str, &str)]) -> ServerAuth { + let auth = ServerAuth::with_key_source(&settings(extra), Box::new(source.clone())) + .expect("server auth"); + // Wait for the first fetch to land: keys installed, or the fetch done and failed. + let keys_expected = matches!( + source.answers.lock().map(|a| a.first().map(Result::is_ok)), + Ok(Some(true)) + ); + let oidc = auth.oidc().expect("oidc on"); + if keys_expected { + wait_until(|| oidc.jwks().key_count() > 0); + } else { + wait_until(|| source.fetches() >= 1); + } + auth +} + +fn wait_until(mut done: impl FnMut() -> bool) { + let start = Instant::now(); + while !done() { + assert!(start.elapsed() < Duration::from_secs(10), "timed out"); + std::thread::sleep(Duration::from_millis(5)); + } +} + +fn peer() -> SocketAddr { + SocketAddr::from(([10, 0, 0, 7], 40_000)) +} + +fn auth(server: &ServerAuth, bearer: &str) -> Result { + let creds = Credentials::new(peer(), Surface::Connect).with_bearer(bearer); + block_on(server.authenticate(&creds)) +} + +#[test] +fn oidc_basic_validation() { + let source = Source::new(vec![Ok(jwks(&[rsa_jwk(rsa(), "k1"), ec_jwk(ec(), "e1")]))]); + let server = server(&source, &[]); + let ok = auth(&server, &token(&rs_header("k1"), &claims(), rs256(rsa()))).expect("valid"); + assert_eq!(ok.method, Method::Oidc); + assert_eq!(ok.principal.name, "alice"); + assert_eq!(ok.principal.groups, ["analysts", "staff"]); + // The highest role any group has. + assert_eq!(ok.role, Some(Role::Operator)); + + let es = token(&json!({"alg": "ES256", "kid": "e1"}), &claims(), es256); + assert_eq!(auth(&server, &es).expect("ES256").principal.name, "alice"); + + // A single-string audience and groups claim; no group in any list means no role. + let mut c = claims(); + c["aud"] = json!(AUDIENCE); + c["groups"] = json!("nobody"); + let ok = auth(&server, &token(&rs_header("k1"), &c, rs256(rsa()))).expect("valid"); + assert_eq!( + (ok.role, ok.principal.groups.as_slice()), + (None, &["nobody".to_owned()][..]) + ); +} + +#[test] +fn oidc_claims_are_checked() { + let source = Source::new(vec![Ok(jwks(&[rsa_jwk(rsa(), "k1")]))]); + let server = server( + &source, + &[("krishiv.auth.failureThrottle.maxFailures", "0")], + ); + let now = unix_now(); + let with = |name: &str, v: Value| { + let mut c = claims(); + if v.is_null() { + c.as_object_mut().expect("object").remove(name); + } else { + c[name] = v; + } + auth(&server, &token(&rs_header("k1"), &c, rs256(rsa()))) + }; + assert_eq!( + with("exp", json!(now - 61)).unwrap_err(), + AuthFailure::Expired + ); + // Within the 60 s skew. + assert!(with("exp", json!(now - 30)).is_ok()); + assert_eq!(with("exp", Value::Null).unwrap_err(), AuthFailure::Invalid); + assert_eq!( + with("exp", json!("tomorrow")).unwrap_err(), + AuthFailure::Invalid + ); + assert_eq!( + with("nbf", json!(now + 120)).unwrap_err(), + AuthFailure::Invalid + ); + assert_eq!( + with("iss", json!("https://idp.example.test")).unwrap_err(), + AuthFailure::Invalid + ); + assert_eq!( + with("aud", json!(["other"])).unwrap_err(), + AuthFailure::Invalid + ); + assert_eq!(with("aud", Value::Null).unwrap_err(), AuthFailure::Invalid); + assert_eq!(with("sub", json!("")).unwrap_err(), AuthFailure::Invalid); + assert_eq!( + with("sub", json!("al\u{202E}ice")).unwrap_err(), + AuthFailure::Invalid + ); + assert_eq!(with("sub", Value::Null).unwrap_err(), AuthFailure::Invalid); + assert_eq!( + with("groups", json!([1])).unwrap_err(), + AuthFailure::Invalid + ); + assert!(with("groups", Value::Null).is_ok_and(|a| a.role.is_none())); + // A signature by another key with the same kid. + let forged = token(&rs_header("k1"), &claims(), rs256(rsa_other())); + assert_eq!(auth(&server, &forged).unwrap_err(), AuthFailure::Invalid); + // A tampered payload. + let good = token(&rs_header("k1"), &claims(), rs256(rsa())); + let parts: Vec<&str> = good.split('.').collect(); + let mut c = claims(); + c["sub"] = json!("mallory"); + let tampered = format!( + "{}.{}.{}", + parts[0], + b64(&serde_json::to_vec(&c).expect("json")), + parts[2] + ); + assert_eq!(auth(&server, &tampered).unwrap_err(), AuthFailure::Invalid); +} + +#[test] +fn oidc_algorithm_pinning() { + let source = Source::new(vec![Ok(jwks(&[rsa_jwk(rsa(), "k1"), ec_jwk(ec(), "e1")]))]); + let server = server( + &source, + &[("krishiv.auth.failureThrottle.maxFailures", "0")], + ); + // alg none, unsigned and with a junk signature. + let none = token(&json!({"alg": "none", "kid": "k1"}), &claims(), |_| { + b"x".to_vec() + }); + assert_eq!(auth(&server, &none).unwrap_err(), AuthFailure::Invalid); + let unsigned = format!("{}.", none.rsplit_once('.').expect("dot").0); + assert_eq!(auth(&server, &unsigned).unwrap_err(), AuthFailure::Invalid); + // HS256 with the RSA public key as the HMAC secret (the classic confusion attack). + let public = rsa().public_key().as_ref().to_vec(); + let hs = token(&json!({"alg": "HS256", "kid": "k1"}), &claims(), |msg| { + aws_lc_rs::hmac::sign( + &aws_lc_rs::hmac::Key::new(aws_lc_rs::hmac::HMAC_SHA256, &public), + msg, + ) + .as_ref() + .to_vec() + }); + assert_eq!(auth(&server, &hs).unwrap_err(), AuthFailure::Invalid); + // ES256 naming the RSA key, and RS256 naming the EC key. + let es_on_rsa = token(&json!({"alg": "ES256", "kid": "k1"}), &claims(), es256); + assert_eq!(auth(&server, &es_on_rsa).unwrap_err(), AuthFailure::Invalid); + let rs_on_ec = token(&rs_header("e1"), &claims(), rs256(rsa())); + assert_eq!(auth(&server, &rs_on_ec).unwrap_err(), AuthFailure::Invalid); + // A verifiable algorithm outside the allowlist. + let rs_only = Source::new(vec![Ok(jwks(&[ec_jwk(ec(), "e1")]))]); + let strict = self::server(&rs_only, &[("krishiv.auth.oidc.algorithms", "RS256")]); + let es = token(&json!({"alg": "ES256", "kid": "e1"}), &claims(), es256); + assert_eq!(auth(&strict, &es).unwrap_err(), AuthFailure::Invalid); + // The header's own key members are never used. + let (n, e) = rsa_components(rsa_other().public_key().as_ref()); + let jwk_header = json!({"alg": "RS256", "jwk": {"kty": "RSA", "n": b64(&n), "e": b64(&e)}}); + let embedded = token(&jwk_header, &claims(), rs256(rsa_other())); + assert_eq!(auth(&server, &embedded).unwrap_err(), AuthFailure::Invalid); + // crit and duplicate members. + let crit = token( + &json!({"alg": "RS256", "kid": "k1", "crit": ["exp"]}), + &claims(), + rs256(rsa()), + ); + assert_eq!(auth(&server, &crit).unwrap_err(), AuthFailure::Invalid); + let dup_header = b64(br#"{"alg":"RS256","kid":"k1","alg":"none"}"#); + let body = b64(&serde_json::to_vec(&claims()).expect("json")); + let input = format!("{dup_header}.{body}"); + let sig = rs256(rsa())(input.as_bytes()); + let dup = format!("{input}.{}", b64(&sig)); + assert_eq!(auth(&server, &dup).unwrap_err(), AuthFailure::Invalid); +} + +#[test] +fn oidc_unknown_kid_refresh_limit() { + // The first fetch has k1 only; every later one also k2. + let source = Source::new(vec![ + Ok(jwks(&[rsa_jwk(rsa(), "k1")])), + Ok(jwks(&[rsa_jwk(rsa(), "k1"), rsa_jwk(rsa_other(), "k2")])), + ]); + let server = server( + &source, + &[("krishiv.auth.failureThrottle.maxFailures", "0")], + ); + assert_eq!(source.fetches(), 1); + // A rotated key: the request waits for one fetch and then passes. + let k2 = token(&rs_header("k2"), &claims(), rs256(rsa_other())); + assert_eq!( + auth(&server, &k2).expect("after refresh").principal.name, + "alice" + ); + assert_eq!(source.fetches(), 2); + // An unknown kid within the minute: refused at once, no fetch. + let k3 = token(&rs_header("k3"), &claims(), rs256(rsa())); + for _ in 0..20 { + assert_eq!(auth(&server, &k3).unwrap_err(), AuthFailure::Invalid); + } + assert_eq!(source.fetches(), 2); + + // The limit itself, on a clock of its own. + let cache = JwksCache::default(); + let t0 = Instant::now(); + assert!(matches!( + cache.lookup(Some("a"), Alg::Rs256, t0), + Lookup::Refresh + )); + assert!(matches!( + cache.lookup(Some("a"), Alg::Rs256, t0 + Duration::from_secs(1)), + Lookup::NoKeys + )); + cache + .install(&jwks(&[rsa_jwk(rsa(), "k1")])) + .expect("install"); + assert!(matches!( + cache.lookup(Some("a"), Alg::Rs256, t0 + Duration::from_secs(2)), + Lookup::Unknown + )); + assert!(matches!(cache.lookup(Some("k1"), Alg::Rs256, t0), Lookup::Keys(_, i) if i.len() == 1)); + // The key's type must fit the algorithm. + assert!(matches!( + cache.lookup(Some("k1"), Alg::Es256, t0 + Duration::from_secs(3)), + Lookup::Unknown + )); + assert!(matches!( + cache.lookup(Some("a"), Alg::Rs256, t0 + ON_DEMAND_EVERY), + Lookup::Refresh + )); + // A token without kid may use any fitting key. + assert!(matches!( + cache.lookup(None, Alg::Rs256, t0), + Lookup::Keys(..) + )); +} + +#[test] +fn oidc_idp_down_uses_cache() { + let source = Source::new(vec![ + Ok(jwks(&[rsa_jwk(rsa(), "k1")])), + Err("connection refused".to_owned()), + ]); + let server = server( + &source, + &[("krishiv.auth.failureThrottle.maxFailures", "0")], + ); + let k1 = token(&rs_header("k1"), &claims(), rs256(rsa())); + assert!(auth(&server, &k1).is_ok()); + // A fetch fails (the provider is down): the cached key still verifies. + let unknown = token(&rs_header("k9"), &claims(), rs256(rsa())); + assert_eq!(auth(&server, &unknown).unwrap_err(), AuthFailure::Invalid); + assert_eq!(source.fetches(), 2); + assert!(auth(&server, &k1).is_ok()); + // A document that does not parse keeps the keys too. + let cache = JwksCache::default(); + cache + .install(&jwks(&[rsa_jwk(rsa(), "k1")])) + .expect("install"); + assert!(cache.install(b"").is_err()); + assert!(matches!( + cache.lookup(Some("k1"), Alg::Rs256, Instant::now()), + Lookup::Keys(..) + )); + + // Never any key: UNAVAILABLE, so clients retry; not counted toward the throttle. + let down = Source::new(vec![Err("connection refused".to_owned())]); + let server = self::server(&down, &[("krishiv.auth.failureThrottle.maxFailures", "1")]); + for _ in 0..3 { + let f = auth(&server, &k1).unwrap_err(); + assert_eq!(f, AuthFailure::Unavailable); + assert!(f.is_retryable()); + } + assert!( + server + .failure_counts() + .iter() + .any(|c| c.method == Method::Oidc && c.count == 3) + ); +} + +#[test] +fn oidc_sync_path_does_not_wait() { + let source = Source::new(vec![Err("down".to_owned())]); + let server = server(&source, &[]); + let k1 = token(&rs_header("k1"), &claims(), rs256(rsa())); + let creds = Credentials::new(peer(), Surface::Admin).with_bearer(&k1); + let before = source.fetches(); + assert_eq!( + server.authenticate_at(&creds, Instant::now()).unwrap_err(), + AuthFailure::Unavailable + ); + // It still asked for a fetch in the background. + wait_until(|| source.fetches() > before); +} + +#[test] +fn static_tokens_work_beside_oidc() { + let secret = "s".repeat(40); + let dir = tempfile::tempdir().expect("tempdir"); + let reference = super::tests::secret_file(dir.path(), "ci", &secret, 0o600); + let source = Source::new(vec![Ok(jwks(&[rsa_jwk(rsa(), "k1")]))]); + let server = server( + &source, + &[ + ("krishiv.auth.tokens.ci.secret", reference.as_str()), + ("krishiv.auth.tokens.ci.principal", "ci"), + ("krishiv.auth.tokens.ci.role", "viewer"), + ], + ); + let ok = auth(&server, &secret).expect("static token"); + assert_eq!( + (ok.method, ok.principal.name.as_str()), + (Method::Token, "ci") + ); + // A dotted token that is not a JWS goes to the static tokens and fails there. + assert_eq!(auth(&server, "a.b.c").unwrap_err(), AuthFailure::Invalid); + assert!( + server + .failure_counts() + .iter() + .any(|c| c.method == Method::Token && c.count == 1) + ); +} + +#[test] +fn oidc_counts_as_a_bearer_method() { + let c = settings(&[]); + assert!(c.is_configured() && c.has_bearer_method()); + let none = AuthConfig::from_settings([]).expect("empty"); + assert!(!none.is_configured() && !none.has_bearer_method()); +} + +#[test] +fn real_keys_parse_from_a_jwk_set() { + let keys = jose::parse_jwks(&jwks(&[rsa_jwk(rsa(), "k1"), ec_jwk(ec(), "e1")])).expect("parse"); + assert!(keys[0].fits(Alg::Rs256) && !keys[0].fits(Alg::Es256)); + assert!(keys[1].fits(Alg::Es256) && !keys[1].fits(Alg::Es384) && !keys[1].fits(Alg::Rs256)); +} + +/// A client certificate with a JWT beside it: the token must name the certificate's principal; +/// a token that cannot be checked now or has expired fails as such, not as a mismatch. +#[test] +fn certificate_with_a_jwt() { + const ETL: &str = "spiffe://krishiv.test/etl"; + const CLIENT: &[u8] = include_bytes!("../../tests/fixtures/tls/client.der"); + let ca = format!( + "file:{}/tests/fixtures/tls/ca.pem", + env!("CARGO_MANIFEST_DIR") + ); + let source = Source::new(vec![Ok(jwks(&[rsa_jwk(rsa(), "k1")]))]); + let server = server( + &source, + &[ + ("krishiv.auth.clientCertificates", "optional"), + ("krishiv.auth.clientCa", ca.as_str()), + ("krishiv.auth.roles.operator", ETL), + ("krishiv.auth.failureThrottle.maxFailures", "0"), + ], + ); + let with_sub = |sub: &str, kid: &str, exp: u64| { + let mut c = claims(); + c["sub"] = json!(sub); + c["exp"] = json!(exp); + token(&rs_header(kid), &c, rs256(rsa())) + }; + let run = |bearer: &str| { + let mut creds = Credentials::new(peer(), Surface::Connect).with_bearer(bearer); + creds.peer_leaf = Some(CLIENT); + server.authenticate_at(&creds, Instant::now()) + }; + let later = unix_now() + 300; + let ok = run(&with_sub(ETL, "k1", later)).expect("same principal"); + assert_eq!((ok.method, ok.role), (Method::Mtls, Some(Role::Operator))); + assert_eq!(ok.principal.name, ETL); + assert_eq!( + run(&with_sub("alice", "k1", later)).unwrap_err(), + AuthFailure::Invalid + ); + assert_eq!( + run(&with_sub(ETL, "k1", unix_now() - 3600)).unwrap_err(), + AuthFailure::Expired + ); + // A key not cached: retry later, not a refusal. + let unknown = run(&with_sub(ETL, "k9", later)).unwrap_err(); + assert_eq!(unknown, AuthFailure::Unavailable); + let counts = server.failure_counts(); + let count = |m: Method| { + counts + .iter() + .filter(|c| c.method == m) + .map(|c| c.count) + .sum::() + }; + assert_eq!((count(Method::Mtls), count(Method::Oidc)), (1, 2)); +} + +/// Certificate names and OIDC role-claim values are separate namespaces. +#[test] +fn certificate_and_group_role_lists_are_separate() { + let source = Source::new(vec![Ok(jwks(&[rsa_jwk(rsa(), "k1")]))]); + let server = server(&source, &[("krishiv.auth.roles.admin", "analysts")]); + let ok = auth(&server, &token(&rs_header("k1"), &claims(), rs256(rsa()))).expect("valid"); + // `analysts` is an admin certificate name, but an operator group. + assert_eq!(ok.role, Some(Role::Operator)); +} + +#[test] +fn expired_tokens_do_not_count_toward_the_throttle() { + let source = Source::new(vec![Ok(jwks(&[rsa_jwk(rsa(), "k1")]))]); + let server = server( + &source, + &[("krishiv.auth.failureThrottle.maxFailures", "2")], + ); + let mut c = claims(); + c["exp"] = json!(unix_now() - 3600); + let expired = token(&rs_header("k1"), &c, rs256(rsa())); + for _ in 0..5 { + assert_eq!(auth(&server, &expired).unwrap_err(), AuthFailure::Expired); + } + assert!(auth(&server, &token(&rs_header("k1"), &claims(), rs256(rsa()))).is_ok()); + // Wrong tokens do. + let forged = token(&rs_header("k1"), &claims(), rs256(rsa_other())); + for _ in 0..2 { + assert_eq!(auth(&server, &forged).unwrap_err(), AuthFailure::Invalid); + } + assert_eq!(auth(&server, &forged).unwrap_err(), AuthFailure::Throttled); +} + +#[test] +fn an_empty_key_set_keeps_the_cached_keys() { + let cache = JwksCache::default(); + assert_eq!(cache.install(&jwks(&[])).expect("empty start"), 0); + cache + .install(&jwks(&[rsa_jwk(rsa(), "k1")])) + .expect("install"); + assert!(cache.install(&jwks(&[])).is_err()); + let only_odd = jwks(&[json!({"kty": "oct", "k": "c2VjcmV0"})]); + assert!(cache.install(&only_odd).is_err()); + assert_eq!(cache.key_count(), 1); +} + +#[test] +fn a_token_without_kid_needs_exactly_one_fitting_key() { + let now = Instant::now(); + let cache = JwksCache::default(); + cache + .install(&jwks(&[rsa_jwk(rsa(), "k1"), ec_jwk(ec(), "e1")])) + .expect("install"); + assert!(matches!(cache.lookup(None, Alg::Rs256, now), Lookup::Keys(_, i) if i == [0])); + cache + .install(&jwks(&[rsa_jwk(rsa(), "k1"), rsa_jwk(rsa_other(), "k2")])) + .expect("install"); + assert!(matches!( + cache.lookup(None, Alg::Rs256, now), + Lookup::Ambiguous + )); + let source = Source::new(vec![Ok(jwks(&[ + rsa_jwk(rsa(), "k1"), + rsa_jwk(rsa_other(), "k2"), + ]))]); + let server = server(&source, &[]); + let no_kid = token(&json!({"alg": "RS256"}), &claims(), rs256(rsa())); + assert_eq!(auth(&server, &no_kid).unwrap_err(), AuthFailure::Invalid); +} diff --git a/crates/krishiv-session/src/auth/server.rs b/crates/krishiv-session/src/auth/server.rs index 4ec2d21ee..9ebb60431 100644 --- a/crates/krishiv-session/src/auth/server.rs +++ b/crates/krishiv-session/src/auth/server.rs @@ -3,8 +3,11 @@ //! //! The chain, in a fixed order: a throttled peer is refused first; then a verified client //! certificate names the principal (when client certificates are on; a bearer token sent with it -//! must name the same principal); then the bearer token goes to the static tokens (OIDC joins in -//! a later unit); no credential is [`AuthFailure::Missing`]. Every failure writes one log +//! must name the same principal); then a bearer token shaped like a JWS goes to OIDC when OIDC is +//! configured (feature `oidc`), any other to the static tokens; no credential is +//! [`AuthFailure::Missing`]. An OIDC token whose key is not cached waits for one JWK set fetch +//! in [`Authenticator::authenticate`]; the synchronous [`ServerAuth::authenticate_at`] does not +//! wait and answers [`AuthFailure::Unavailable`] instead. Every failure writes one log //! record (target `krishiv::auth`: surface, method, peer, failure; never the credential) and //! counts once per `(surface, method)`. @@ -16,7 +19,11 @@ use krishiv_common::error::KrishivError; use krishiv_common::principal::Principal; use super::config::{AuthConfig, RoleLists, SPARK_TOKEN_NAME}; +#[cfg(feature = "oidc")] +use super::jose::{self, Identity}; use super::mtls::{ClientCertificates, MtlsConfig}; +#[cfg(feature = "oidc")] +use super::oidc::{Oidc, Verified, unix_now}; use super::throttle::FailureThrottle; use super::tokens::StaticTokens; use super::{AuthFailure, Authenticated, Authenticator, Credentials, Method, Surface}; @@ -43,17 +50,60 @@ pub struct ServerAuth { mtls: MtlsConfig, roles: RoleLists, throttle: FailureThrottle, + /// The OIDC verifier, when configured. + #[cfg(feature = "oidc")] + oidc: Option, + /// The roles of OIDC role-claim values. + #[cfg(feature = "oidc")] + oidc_roles: RoleLists, /// Indexed by `surface * Method::ALL.len() + method`. failures: [AtomicU64; Surface::ALL.len() * Method::ALL.len()], } impl ServerAuth { - /// Builds the authenticator: resolves and hashes every token. Logs a warning when Spark's - /// all-powerful `spark.connect.authenticate.token` is in use. + /// Builds the authenticator: resolves and hashes every token, and starts the OIDC key + /// fetch thread when OIDC is configured. Logs a warning when Spark's all-powerful + /// `spark.connect.authenticate.token` is in use. /// /// # Errors - /// `KRISHIV_INVALID_CONFIG_VALUE` from [`StaticTokens::build`]. + /// `KRISHIV_INVALID_CONFIG_VALUE` from [`StaticTokens::build`], or when the OIDC thread + /// cannot start. pub fn new(config: &AuthConfig) -> Result { + #[cfg(feature = "oidc")] + let oidc = if config.oidc.is_on() { + Some(Oidc::start(&config.oidc)?) + } else { + None + }; + Self::build( + config, + #[cfg(feature = "oidc")] + oidc, + ) + } + + /// [`ServerAuth::new`] with an OIDC verifier over an in-memory key source. + #[cfg(test)] + #[cfg(feature = "oidc")] + pub(crate) fn with_key_source( + config: &AuthConfig, + source: Box, + ) -> Result { + let oidc = Oidc::with_source(&config.oidc, source)?; + Self::build(config, Some(oidc)) + } + + /// The OIDC verifier (tests). + #[cfg(test)] + #[cfg(feature = "oidc")] + pub(crate) fn oidc(&self) -> Option<&Oidc> { + self.oidc.as_ref() + } + + fn build( + config: &AuthConfig, + #[cfg(feature = "oidc")] oidc: Option, + ) -> Result { let tokens = StaticTokens::build(&config.tokens)?; if config.tokens.iter().any(|t| t.name == SPARK_TOKEN_NAME) { tracing::warn!( @@ -67,6 +117,10 @@ impl ServerAuth { mtls: config.mtls.clone(), roles: config.roles.clone(), throttle: FailureThrottle::new(config.throttle), + #[cfg(feature = "oidc")] + oidc, + #[cfg(feature = "oidc")] + oidc_roles: config.oidc.roles.clone(), failures: std::array::from_fn(|_| AtomicU64::new(0)), }) } @@ -75,7 +129,11 @@ impl ServerAuth { /// (loopback only; the listener enforces that). #[must_use] pub fn is_enabled(&self) -> bool { - !self.tokens.is_empty() || self.mtls.is_on() + #[cfg(feature = "oidc")] + let oidc = self.oidc.is_some(); + #[cfg(not(feature = "oidc"))] + let oidc = false; + !self.tokens.is_empty() || self.mtls.is_on() || oidc } /// Authenticates `credentials` at `now` (the clock is a parameter for tests). @@ -106,6 +164,24 @@ impl ServerAuth { None if credentials.malformed => None, None => return Err(self.fail(credentials, Method::None, AuthFailure::Missing)), }; + #[cfg(feature = "oidc")] + if let Some(t) = token + && self.oidc.is_some() + && jose::looks_like_jws(t) + { + return self.finish_oidc(credentials, self.verify_oidc(t, now, false), now); + } + self.authenticate_static(credentials, token, now) + } + + /// A bearer token (or a malformed `Authorization` header, `None`) against the static + /// tokens. + fn authenticate_static( + &self, + credentials: &Credentials<'_>, + token: Option<&str>, + now: Instant, + ) -> Result { // A token longer than any credential this server issues is refused unhashed. let token = token.filter(|t| t.len() <= MAX_TOKEN_BYTES); match token.and_then(|t| self.tokens.lookup(t)) { @@ -115,37 +191,40 @@ impl ServerAuth { Method::Token, )), None => { - if self.throttle.record_failure(ip, now) { - tracing::warn!( - target: "krishiv::auth", - peer = %ip, - "authentication failures from this address are throttled" - ); - } + self.record_failure(credentials, now); Err(self.fail(credentials, Method::Token, AuthFailure::Invalid)) } } } + /// Counts a failure toward the peer's throttle, logging when it starts a block. + fn record_failure(&self, credentials: &Credentials<'_>, now: Instant) { + let ip = credentials.peer.ip(); + if self.throttle.record_failure(ip, now) { + tracing::warn!( + target: "krishiv::auth", + peer = %ip, + "authentication failures from this address are throttled" + ); + } + } + /// A client with a verified certificate: the configured rule names the principal. A bearer - /// token sent as well must name the same principal (a confused gateway is refused). + /// token sent as well must name the same principal (a confused gateway is refused). An OIDC + /// token that cannot be checked now, or has expired, fails as such (retryable, or "expired"), + /// not as a mismatch. fn authenticate_certificate( &self, credentials: &Credentials<'_>, leaf: &[u8], now: Instant, ) -> Result { - let ip = credentials.peer.ip(); - let named = self.mtls.principal_of(leaf); - let agrees = |name: &str| match (credentials.bearer, credentials.malformed) { - (None, false) => true, - (Some(t), _) if t.len() <= MAX_TOKEN_BYTES => { - self.tokens.lookup(t).is_some_and(|(p, _)| p.name == name) - } - _ => false, + let Some((name, kind)) = self.mtls.principal_of(leaf) else { + self.record_failure(credentials, now); + return Err(self.fail(credentials, Method::Mtls, AuthFailure::Invalid)); }; - match named { - Some((name, kind)) if agrees(&name) => { + match self.bearer_agrees(credentials, &name, now) { + Ok(true) => { let role = self.roles.role_of(&name); Ok(Authenticated::new( Principal::new(name, kind), @@ -153,10 +232,39 @@ impl ServerAuth { Method::Mtls, )) } - _ => { - self.throttle.record_failure(ip, now); + Ok(false) => { + self.record_failure(credentials, now); Err(self.fail(credentials, Method::Mtls, AuthFailure::Invalid)) } + Err(failure) => Err(self.fail(credentials, Method::Oidc, failure)), + } + } + + /// Whether the bearer token sent next to a certificate names `name` (no token agrees). An + /// OIDC token is checked with the cached keys only; one that cannot be checked now or has + /// expired is that failure. + fn bearer_agrees( + &self, + credentials: &Credentials<'_>, + name: &str, + #[cfg_attr(not(feature = "oidc"), expect(unused_variables, reason = "OIDC only"))] + now: Instant, + ) -> Result { + match (credentials.bearer, credentials.malformed) { + (None, false) => Ok(true), + #[cfg(feature = "oidc")] + (Some(t), _) if self.oidc.is_some() && jose::looks_like_jws(t) => { + match self.verify_oidc(t, now, false) { + Ok(Some(id)) => Ok(id.principal == name), + Ok(None) => Err(AuthFailure::Unavailable), + Err(AuthFailure::Invalid) => Ok(false), + Err(f) => Err(f), + } + } + (Some(t), _) if t.len() <= MAX_TOKEN_BYTES => { + Ok(self.tokens.lookup(t).is_some_and(|(p, _)| p.name == name)) + } + _ => Ok(false), } } @@ -196,8 +304,101 @@ impl Authenticator for ServerAuth { &'a self, credentials: &'a Credentials<'a>, ) -> BoxFuture<'a, Result> { - let result = self.authenticate_at(credentials, Instant::now()); - Box::pin(std::future::ready(result)) + Box::pin(async move { + #[cfg(feature = "oidc")] + if let Some(oidc) = &self.oidc + && let Some(token) = self.waiting_oidc_token(credentials) + { + let now = Instant::now(); + if self.throttle.is_blocked(credentials.peer.ip(), now) { + return Err(self.fail(credentials, Method::Throttle, AuthFailure::Throttled)); + } + let verdict = match self.verify_oidc(token, now, true) { + // No cached key fits: wait for one fetch, then decide on what it brought. + Ok(None) => { + drop(oidc.refresh().await); + self.verify_oidc(token, Instant::now(), false) + } + v => v, + }; + return self.finish_oidc(credentials, verdict, Instant::now()); + } + self.authenticate_at(credentials, Instant::now()) + }) + } +} + +#[cfg(feature = "oidc")] +impl ServerAuth { + /// The OIDC verdict on `token`: `Ok(Some(identity))`, a failure, or `Ok(None)` when the + /// caller may wait for a key fetch and should. + fn verify_oidc( + &self, + token: &str, + now: Instant, + may_refresh: bool, + ) -> Result, AuthFailure> { + let Some(oidc) = &self.oidc else { + return Err(AuthFailure::Invalid); + }; + match oidc.verify(token, now, unix_now(), may_refresh) { + Verified::Ok(identity) => Ok(Some(identity)), + Verified::Failed(f) => Err(f), + Verified::Refresh => Ok(None), + } + } + + /// Turns an OIDC verdict into the result: the role from the groups, the failure logged, + /// counted and (unless the provider is unreachable) throttled. + fn finish_oidc( + &self, + credentials: &Credentials<'_>, + verdict: Result, AuthFailure>, + now: Instant, + ) -> Result { + match verdict { + Ok(Some(identity)) => Ok(self.oidc_principal(identity)), + // Only `authenticate` asks to wait; reaching here without waiting is a refusal. + Ok(None) => Err(self.fail(credentials, Method::Oidc, AuthFailure::Unavailable)), + Err(failure) => { + // Only a token that is wrong counts toward the peer's throttle: an expired one + // was signed by the provider (a client retrying it is not guessing), and an + // unreachable provider is not the client's fault. + if failure == AuthFailure::Invalid { + self.record_failure(credentials, now); + } + Err(self.fail(credentials, Method::Oidc, failure)) + } + } + } + + /// An OIDC principal: a user, with its groups; the role is the highest any role-claim value + /// has in `krishiv.auth.oidc.roles.*` (the lists are disjoint, so this is "the first list, + /// in order admin, operator, viewer, holding any of the values"). + fn oidc_principal(&self, identity: Identity) -> Authenticated { + let role = identity + .groups + .iter() + .filter_map(|g| self.oidc_roles.role_of(g)) + .max(); + let mut principal = Principal::new( + identity.principal, + krishiv_common::principal::PrincipalKind::User, + ); + principal.groups = identity.groups; + Authenticated::new(principal, role, Method::Oidc) + } + + /// The bearer token of a request that goes to OIDC without a client certificate: the one + /// path that may wait for a key fetch. + fn waiting_oidc_token<'c>(&self, credentials: &Credentials<'c>) -> Option<&'c str> { + let certificate_path = self.mtls.is_on() + && (credentials.peer_leaf.is_some() + || self.mtls.client_certificates == ClientCertificates::Required); + if certificate_path { + return None; + } + credentials.bearer.filter(|t| jose::looks_like_jws(t)) } } diff --git a/crates/krishiv-session/src/auth/tests.rs b/crates/krishiv-session/src/auth/tests.rs index 0a20f6faf..44f450992 100644 --- a/crates/krishiv-session/src/auth/tests.rs +++ b/crates/krishiv-session/src/auth/tests.rs @@ -24,7 +24,7 @@ fn peer(last: u8) -> SocketAddr { } /// A secret file with `contents` and `mode`. -fn secret_file(dir: &Path, name: &str, contents: &str, mode: u32) -> String { +pub(super) fn secret_file(dir: &Path, name: &str, contents: &str, mode: u32) -> String { let path = dir.join(name); let mut f = std::fs::File::create(&path).unwrap(); f.write_all(contents.as_bytes()).unwrap(); diff --git a/crates/krishiv-session/src/auth/tls_config.rs b/crates/krishiv-session/src/auth/tls_config.rs index 5f17b5e3c..88077e349 100644 --- a/crates/krishiv-session/src/auth/tls_config.rs +++ b/crates/krishiv-session/src/auth/tls_config.rs @@ -127,7 +127,7 @@ pub fn check_listener( "a non-loopback address needs an authentication method (krishiv.auth.*)", )); } - if !auth.tokens.is_empty() && !tls.is_on() { + if auth.has_bearer_method() && !tls.is_on() { return Err(invalid( "krishiv.server.host", &shown, diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 313d0dea2..386b93b29 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -11,10 +11,11 @@ publish = false [package.metadata] cargo-fuzz = true -# `capsule_import` includes crates/krishiv-python/src/stream/ffi.rs by path, which is what -# uses these two; cargo-machete cannot see through the path include. +# `capsule_import` includes crates/krishiv-python/src/stream/ffi.rs by path (arrow-data, +# krishiv-common) and `jwt_parse` includes krishiv-session's auth/jose.rs (serde, serde_json); +# cargo-machete cannot see through a path include. [package.metadata.cargo-machete] -ignored = ["arrow-data", "krishiv-common"] +ignored = ["arrow-data", "krishiv-common", "serde", "serde_json"] [dependencies] libfuzzer-sys = "0.4.10" @@ -32,6 +33,8 @@ krishiv-sql = { path = "../crates/krishiv-sql" } krishiv-types = { path = "../crates/krishiv-types" } krishiv-testkit = { path = "../crates/krishiv-testkit" } object_store = { version = "=0.14.2", default-features = false } +serde = { version = "1.0.229", default-features = false, features = ["std"] } +serde_json = { version = "1.0.154", default-features = false, features = ["std"] } tokio = { version = "1.53", default-features = false, features = ["rt", "time"] } [[bin]] @@ -62,6 +65,13 @@ test = false doc = false bench = false +[[bin]] +name = "jwt_parse" +path = "fuzz_targets/jwt_parse.rs" +test = false +doc = false +bench = false + [[bin]] name = "capsule_import" path = "fuzz_targets/capsule_import.rs" diff --git a/fuzz/fuzz_targets/jwt_parse.rs b/fuzz/fuzz_targets/jwt_parse.rs new file mode 100644 index 000000000..9a34ca2e7 --- /dev/null +++ b/fuzz/fuzz_targets/jwt_parse.rs @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +//! The OIDC token and key-set parsers (P3-T002 PR-14d; design §3.3, threat model §4.9): a JWS +//! compact token, a JWK set and a discovery document arrive from clients and the identity +//! provider, before any signature is checked. Each parser must return a value or an error for +//! any bytes, never panic, loop or allocate without bound. The first byte picks the parser. +#![no_main] + +#[path = "../../crates/krishiv-session/src/auth/jose.rs"] +mod jose; + +libfuzzer_sys::fuzz_target!(|data: &[u8]| { + let Some((&which, rest)) = data.split_first() else { + return; + }; + match which % 3 { + 0 => { + if let Ok(text) = std::str::from_utf8(rest) { + let shaped = jose::looks_like_jws(text); + if let Ok(jws) = jose::parse_jws(text) { + // A parsed token has the shape the router looks for. + assert!(shaped); + assert!(jws.signing_input.len() < text.len()); + let rules = jose::ClaimRules { + issuer: "https://idp.test/", + audience: "krishiv", + principal_claim: "sub", + role_claim: "groups", + skew_secs: 60, + }; + if let Ok(id) = jose::check_claims(&jws.claims, &rules, 1_800_000_000) { + assert!(!id.principal.is_empty()); + assert!(id.groups.len() <= jose::MAX_GROUPS); + } + } + } + } + 1 => { + if let Ok(keys) = jose::parse_jwks(rest) { + assert!(keys.len() <= jose::MAX_JWKS_KEYS); + } + } + _ => { + let _ = jose::discovered_jwks_uri(rest, "https://idp.test/"); + } + } +}); From c61738527b1abf5797bafbbac3898ca60554195c Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Fri, 13 Nov 2026 09:43:00 +0000 Subject: [PATCH 16/47] =?UTF-8?q?session,=20common:=20path=20confinement?= =?UTF-8?q?=20(P3-T002,=20ADR=200046=20=C2=A73a):=20PathPrefixPolicy=20all?= =?UTF-8?q?ows=20object-store=20prefixes=20by=20store=20and=20whole=20key?= =?UTF-8?q?=20segments;=20from=5Fconfig=20reads=20krishiv.io.local.allowed?= =?UTF-8?q?Roots=20and=20krishiv.io.objectStore.allowedPrefixes=20(startup?= =?UTF-8?q?=20only,=20default=20empty,=20every=20role)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-common/src/config/registry.rs | 36 ++++ .../src/config/registry/index.rs | 5 + crates/krishiv-common/src/config/tests.rs | 20 ++ crates/krishiv-session/CLAUDE.md | 2 +- crates/krishiv-session/src/access.rs | 119 ++++++++++- .../krishiv-session/tests/path_confinement.rs | 196 ++++++++++++++++++ 6 files changed, 371 insertions(+), 7 deletions(-) create mode 100644 crates/krishiv-session/tests/path_confinement.rs diff --git a/crates/krishiv-common/src/config/registry.rs b/crates/krishiv-common/src/config/registry.rs index 207affd55..ac376c389 100644 --- a/crates/krishiv-common/src/config/registry.rs +++ b/crates/krishiv-common/src/config/registry.rs @@ -98,6 +98,8 @@ pub static REGISTRY: &[&ConfigEntry] = &[ &IO_METADATA_CACHE_BYTES, &IO_CACHE_ENABLED, &IO_CACHE_PATH, + &IO_LOCAL_ALLOWED_ROOTS, + &IO_OBJECT_STORE_ALLOWED_PREFIXES, &IO_CACHE_DISK_BYTES, &IO_CACHE_BLOCK_BYTES, &IO_CACHE_MEMORY_BYTES, @@ -1082,6 +1084,40 @@ pub static IO_CACHE_PATH: ConfigEntry = ConfigEntry { spark_alias: None, }; +/// `krishiv.io.local.allowedRoots`. +pub static IO_LOCAL_ALLOWED_ROOTS: ConfigEntry = ConfigEntry { + key: "krishiv.io.local.allowedRoots", + value_type: ValueType::String, + default: None, + doc: "Server mode (authentication configured) only: a comma-separated list of existing local \ + directories that reads and writes by path may use, for every role, admin included \ + (ADR 0046 §3a). A path is compared after `.`, `..` and symbolic links are resolved. \ + Unset or empty: no local path is allowed. Embedded use and a server without \ + authentication do not read it.", + scope: Scope::Cluster, + settable_by: SettableBy::Startup, + version_added: "0.2.0", + spark_alias: None, +}; + +/// `krishiv.io.objectStore.allowedPrefixes`. +pub static IO_OBJECT_STORE_ALLOWED_PREFIXES: ConfigEntry = ConfigEntry { + key: "krishiv.io.objectStore.allowedPrefixes", + value_type: ValueType::String, + default: None, + doc: "Server mode (authentication configured) only: a comma-separated list of object-store \ + prefixes (`s3://bucket/prefix`, `gs://bucket`, `abfss://container@account/prefix`) that \ + reads and writes by path may use, for every role, admin included (ADR 0046 §3a). A \ + prefix matches whole key segments (`s3://b/data` does not match `s3://b/data2`); `s3` \ + and `s3a` name the same store. It is also the one opt-in for the server's own \ + (ambient) credentials. Unset or empty: no object-store path is allowed. Embedded use \ + and a server without authentication do not read it.", + scope: Scope::Cluster, + settable_by: SettableBy::Startup, + version_added: "0.2.0", + spark_alias: None, +}; + /// `krishiv.io.cache.diskBytes`. pub static IO_CACHE_DISK_BYTES: ConfigEntry = ConfigEntry { key: "krishiv.io.cache.diskBytes", diff --git a/crates/krishiv-common/src/config/registry/index.rs b/crates/krishiv-common/src/config/registry/index.rs index 6b55ce028..133fc56a7 100644 --- a/crates/krishiv-common/src/config/registry/index.rs +++ b/crates/krishiv-common/src/config/registry/index.rs @@ -64,6 +64,7 @@ pub static INDEX: &[(&str, &ConfigEntry)] = &[ ("spark.sql.shuffle.partitions", &SHUFFLE_PARTITIONS_HINT), ("krishiv.exec.sort.hashBuckets", &EXEC_SORT_HASH_BUCKETS), ("krishiv.io.coalesceWasteBytes", &IO_COALESCE_WASTE_BYTES), + ("krishiv.io.local.allowedRoots", &IO_LOCAL_ALLOWED_ROOTS), ("krishiv.io.metadataCacheBytes", &IO_METADATA_CACHE_BYTES), ("krishiv.io.multipartPartBytes", &IO_MULTIPART_PART_BYTES), ("krishiv.optimizer.joinDpLimit", &JOIN_DP_LIMIT), @@ -241,6 +242,10 @@ pub static INDEX: &[(&str, &ConfigEntry)] = &[ "krishiv.exec.join.sharedBroadcastBytes", &EXEC_JOIN_SHARED_BROADCAST_BYTES, ), + ( + "krishiv.io.objectStore.allowedPrefixes", + &IO_OBJECT_STORE_ALLOWED_PREFIXES, + ), ( "krishiv.io.parquet.bloomFilter.enabled", &IO_PARQUET_BLOOM_FILTER_ENABLED, diff --git a/crates/krishiv-common/src/config/tests.rs b/crates/krishiv-common/src/config/tests.rs index 9c5e12bb0..64eabe948 100644 --- a/crates/krishiv-common/src/config/tests.rs +++ b/crates/krishiv-common/src/config/tests.rs @@ -1265,3 +1265,23 @@ fn fuse_probe_select_defaults_on_and_parses() { Some(&ConfigValue::Bool(false)) ); } + +#[test] +fn path_confinement_entries_default_to_empty_and_are_startup_only() { + let mut c = Config::new(); + for entry in [&IO_LOCAL_ALLOWED_ROOTS, &IO_OBJECT_STORE_ALLOWED_PREFIXES] { + assert_eq!(c.get(entry), None, "{}", entry.key); + assert_eq!(entry.scope, Scope::Cluster, "{}", entry.key); + assert_eq!(entry.settable_by, SettableBy::Startup, "{}", entry.key); + } + c.set( + "krishiv.io.objectStore.allowedPrefixes", + "s3://lake/data, gs://shared", + ) + .unwrap(); + assert_eq!( + c.get(&IO_OBJECT_STORE_ALLOWED_PREFIXES) + .and_then(ConfigValue::as_str), + Some("s3://lake/data, gs://shared") + ); +} diff --git a/crates/krishiv-session/CLAUDE.md b/crates/krishiv-session/CLAUDE.md index 24da67fae..b5a0ad754 100644 --- a/crates/krishiv-session/CLAUDE.md +++ b/crates/krishiv-session/CLAUDE.md @@ -23,7 +23,7 @@ Paths are relative to this crate. - `src/dataframe.rs` DataFrame transformations (IR only) and async actions; `src/column.rs` and `src/functions.rs` user API - `src/query_fold.rs` the bookkeeping around one running query: `QueryStart` (counters read at start), `QueryFold`/`MetricStream`/`PendingFold` (the one fold of its metrics into the engine series and `system.queries` when the stream ends, fails or is dropped; holds the admission permit), `QueuedRow` (the history row of a query waiting for admission) - `src/admission.rs` the admission seam (E5): `AdmissionController::admit(&QueryRequest) -> Permit` asked once per query in `run_on_query_logged`; default `Unlimited`; a waiting `admit` shows the query `queued` in `system.queries`, a refusal closes the row `failed`, a drop while queued `cancelled`; the permit (slot, optional per-query memory budget) lives in the query's `QueryFold` and releases on drop; `tests/admission.rs` -- `src/access.rs` `StatementInfo` / `Session::statement_info` (class + read securables with columns), write/DDL checks (`authorize_statement`), the `krishiv::audit` denial record, `PathPrefixPolicy` +- `src/access.rs` `StatementInfo` / `Session::statement_info` (class + read securables with columns), write/DDL checks (`authorize_statement`), the `krishiv::audit` denial record, `PathPrefixPolicy` (local roots; object-store prefixes by store and whole key segments, `with_object_prefixes`; `from_config` is the server-mode confinement of ADR 0046 §3a from `krishiv.io.local.allowedRoots` / `krishiv.io.objectStore.allowedPrefixes`, default empty, every role; `tests/path_confinement.rs`) - `src/auth/` (P3-T002, design `design/P3-T002-authentication.md`, ADR 0046) server authentication: `Role` (viewer < operator < admin), `Credentials` / `Authenticated` / `AuthFailure` (Spark's two `UNAUTHENTICATED` messages), the `Authenticator` trait and `ServerAuth` (throttle, then static tokens; one `krishiv::auth` warn record and one `(surface, method)` counter per failure, never the credential); `config.rs` `AuthConfig::from_settings` (`krishiv.auth.tokens..{secret,principal,kind,role}`, `spark.connect.authenticate.token` = one admin token named `spark`, `krishiv.auth.failureThrottle.*`; unknown `krishiv.auth.*` keys refused); `secret_ref.rs` `env:`/`file:` references (a file readable by group or others refuses startup); `tokens.rs` SHA-256 digests compared in constant time against every entry (secrets >= 32 bytes, no two tokens share one); `throttle.rs` per-address failure blocks, bounded at 65,536 addresses; `mtls.rs` client certificates (`krishiv.auth.clientCertificates` off/optional/required, `clientCa`, `mtls.principalFrom` san-uri/san-dns/cn: exactly one value of that kind or refused, `mtls.userPrincipals`; roles from `krishiv.auth.roles.{viewer,operator,admin}` lists via `config.rs` `RoleLists`; a bearer token next to a certificate must name the same principal; `required` never falls back to a token); `der.rs` `CertNames::from_der`, the bounded no-recursion DER walk of the leaf's subject CN and DNS/URI SANs (fuzz target `fuzz/fuzz_targets/cert_names.rs` includes it by path); `tls_config.rs` `TlsConfig` (`krishiv.server.tls.{certFile,keyFile,minVersion}`) and `check_listener` (startup refusals: non-loopback without auth, non-loopback tokens without TLS, client certificates without TLS); `tls.rs` (feature `tls`) `ServerTls`, the rustls `ServerConfig` on aws-lc-rs with h2/http1.1 ALPN, WebPKI client verifier, a key file checked like a secret file and `reload()` swapping the certified key (a failed reload keeps the old one); `mtls_tests.rs` with in-memory handshakes against `tests/fixtures/tls/`; `oidc_config.rs` `OidcConfig` (`krishiv.auth.oidc.{issuer,audience,jwksUri,algorithms,principalClaim,roleClaim,clockSkew,jwksRefresh,allowInsecureIssuer}` and `krishiv.auth.oidc.roles.{viewer,operator,admin}`, the role-claim values by role, a namespace separate from the certificate names of `krishiv.auth.roles.*`; refused without feature `oidc`; locations https only unless the test flag, with a host, no userinfo, no fragment); `jose.rs` the pure JWS/JWK set/discovery parsers (16 KiB token cap, canonical unpadded base64url, duplicate JSON members refused, `alg` only RS256/384/512 and ES256/384, `crit` refused, header key members ignored; `check_claims`: `exp`/`nbf` with skew, exact `iss`, `aud`, principal and groups; fuzz target `fuzz/fuzz_targets/jwt_parse.rs` includes it by path); `oidc.rs` (feature `oidc`) signatures on aws-lc-rs (no `none`, no HMAC), `JwksCache` (an unknown `kid` asks for one fetch per minute, the first such request waits for it, others fail at once; a token without `kid` needs exactly one fitting key; a failed fetch or a set with no usable key keeps the keys; no keys at all is `Unavailable`) and the `krishiv-jwks` fetch thread (`HttpKeySource`: blocking reqwest, no redirects, 1 MiB cap, one 10 s deadline per fetch); the role of an OIDC principal is the highest any of its role-claim values has in the OIDC role lists; only an invalid token counts toward the throttle; `jose_tests.rs` (every build), `oidc_tests.rs` with keys generated per run, `oidc_http_tests.rs` against a loopback HTTP server; `roles.rs` `RolePolicy`, the ADR 0046 §3 matrix as an `AccessPolicy` (viewer: `Browse`, own `system.*` rows, `/metrics`; operator: `Select`, `Usage`, `Monitor`, `Operate`; admin: `Modify`/`Create`/`Drop`/`Manage`; client code at `krishiv.auth.udf.minRole`; `Impersonate` never; no role denies all), `tests/roles_matrix.rs`. Metadata statements need `Browse` (`authorize` falls back to `Select` for providers that know only `Select`); `ADMIN`-class statements need `Manage` on `System("cluster")` - `src/partitions.rs` partitioned and bucketed tables: the layout `CREATE TABLE` stores (partition columns last), the partitions the catalog tracks (`ADD PARTITION`, `SET LOCATION` and static `INSERT` values cast to the column types, `DROP PARTITION` of a managed table: log first, then the directories; each partitioned table has a read/write lock (`InMemoryCatalogStore::partition_ddl` / `partition_write`, keyed by location, weak entries): `RENAME`/`DROP PARTITION`, `MSCK REPAIR` and `TRUNCATE` hold it exclusively (across their log commit retries) and re-read the entry under it, an `INSERT` holds it shared from its write to its partition registration; external tables refused, registered after `INSERT`/CTAS), `DESCRIBE ... PARTITION`, `SHOW PARTITIONS`, `ANALYZE ... PARTITION`; `tests/partitioned_tables.rs` - `src/column_stats.rs` `ANALYZE TABLE .. FOR COLUMNS` (`CommandUtils.statExprs`): one aggregate over the table, kept in `CatalogTable::column_stats` (the catalog is in memory, no on-disk format), printed by `DESCRIBE EXTENDED t col`; a write, `TRUNCATE`, `ADD PARTITION`, `DROP PARTITION` or `SET LOCATION` drops them (`updateTableStats`); published only to the entry the aggregate read (`CatalogTable::incarnation`, set by the store at create, kept by `replace_table`), so a table dropped and created again meanwhile gets none, nor one whose data changed after it was read (`CatalogTable::data_version`, moved by `data_changed`, which every stats-clearing change calls); `MSCK REPAIR` keeps them, as Spark 4.2.0 does; `tests/char_varchar_write.rs` diff --git a/crates/krishiv-session/src/access.rs b/crates/krishiv-session/src/access.rs index 06ed21f67..024454a78 100644 --- a/crates/krishiv-session/src/access.rs +++ b/crates/krishiv-session/src/access.rs @@ -13,9 +13,15 @@ use std::path::{Component, Path, PathBuf}; use std::sync::Arc; -use krishiv_common::error::{ErrorClass, KrishivError, KrishivUnsupportedFeature, PathNotFound}; +use krishiv_common::config::{ + Config, ConfigValue, IO_LOCAL_ALLOWED_ROOTS, IO_OBJECT_STORE_ALLOWED_PREFIXES, +}; +use krishiv_common::error::{ + ErrorClass, KrishivError, KrishivInvalidConfigValue, KrishivUnsupportedFeature, PathNotFound, +}; use krishiv_common::secret::OptionValue; use krishiv_exec::files::LocatedPath; +use krishiv_io::{StoreId, parse_object_url}; use krishiv_plan::SourceId; use krishiv_plan::analyzer::{ AccessDecision, AccessPolicy, BoxFuture, PolicyVersion, Principal, Privilege, Securable, @@ -614,8 +620,9 @@ pub(crate) fn still_physical(session: &Session, at: &LocatedPath) -> Result<(), /// A path is compared in the qualified form reads and writes use /// (`file:/a/b`, `.` and `..` already resolved), after resolving symbolic /// links in the part of it that exists, against the roots with their links -/// resolved. Anything else is denied: other schemes (`s3://`, ...), paths -/// with credentials, any path containing `%` (percent-encoded text would +/// resolved. Object-store paths are allowed only under the prefixes of +/// [`Self::with_object_prefixes`] (none by default). Anything else is +/// denied: other schemes, paths with credentials, any path containing `%` (percent-encoded text would /// not be decoded the way the comparison reads it, so even a literal `%` /// in a file name is refused) and relative or unresolved paths. Session /// writes go to the resolved directory, which is opened once and then used @@ -638,13 +645,22 @@ pub(crate) fn still_physical(session: &Session, at: &LocatedPath) -> Result<(), #[derive(Clone)] pub struct PathPrefixPolicy { roots: Vec, + prefixes: Vec, inner: Arc, } +/// One allowed object-store prefix: a store and the whole key segments under it. +#[derive(Debug, Clone, PartialEq, Eq)] +struct ObjectPrefix { + store: StoreId, + segments: Vec, +} + impl std::fmt::Debug for PathPrefixPolicy { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("PathPrefixPolicy") .field("roots", &self.roots) + .field("prefixes", &self.prefixes) .finish_non_exhaustive() } } @@ -669,16 +685,78 @@ impl PathPrefixPolicy { }) }) .collect::, _>>()?; - Ok(Self { roots, inner }) + Ok(Self { + roots, + prefixes: Vec::new(), + inner, + }) } - /// Whether the qualified path `path` lies under a root. + /// Also allows object-store paths under `prefixes` (`s3://bucket/prefix`, `gs://bucket`, + /// `abfss://container@account/prefix`): a path is allowed when it names the same store + /// (`s3` and `s3a` are one) and its key starts with the prefix's whole segments, so + /// `s3://b/data` allows `s3://b/data/x` but not `s3://b/data2` (ADR 0046 §3a). + /// + /// # Errors + /// `KRISHIV_INVALID_CONFIG_VALUE` for a prefix that is not an object-store URL with a + /// bucket, or that holds credentials, `%`, a query or fragment, or a `.`, `..` or empty + /// segment. + pub fn with_object_prefixes>( + mut self, + prefixes: impl IntoIterator, + ) -> Result { + for prefix in prefixes { + let prefix = prefix.as_ref(); + let parsed = parse_object_prefix(prefix).ok_or_else(|| { + KrishivError::from(KrishivInvalidConfigValue { + value: format!("'{prefix}'"), + key: format!("\"{}\"", IO_OBJECT_STORE_ALLOWED_PREFIXES.key), + reason: "expected an object-store prefix such as s3://bucket/prefix, without \ + credentials, `%`, a query, or `.`, `..` or empty segments" + .to_owned(), + }) + })?; + self.prefixes.push(parsed); + } + Ok(self) + } + + /// The server-mode confinement of ADR 0046 §3a around `inner`: local paths under + /// `krishiv.io.local.allowedRoots`, object-store paths under + /// `krishiv.io.objectStore.allowedPrefixes` (both comma separated, default empty, so + /// nothing by path), and nothing else, for every role. + /// + /// # Errors + /// `PATH_NOT_FOUND` for a root that does not exist; `KRISHIV_INVALID_CONFIG_VALUE` for a + /// prefix [`Self::with_object_prefixes`] refuses. + pub fn from_config( + config: &Config, + inner: Arc, + ) -> Result { + let list = |entry| match config.get(entry) { + Some(ConfigValue::String(raw)) => raw + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(), + _ => Vec::new(), + }; + Self::new(list(&IO_LOCAL_ALLOWED_ROOTS), inner)? + .with_object_prefixes(list(&IO_OBJECT_STORE_ALLOWED_PREFIXES)) + } + + /// Whether the qualified path `path` lies under a root or an object-store prefix. fn allows(&self, path: &OptionValue) -> bool { let Some(text) = path.as_plain() else { return false; }; let Some(local) = text.strip_prefix("file:") else { - return false; + return parse_object_prefix(text).is_some_and(|p| { + self.prefixes + .iter() + .any(|a| a.store == p.store && p.segments.starts_with(&a.segments)) + }); }; let local = Path::new(local); let normal = local.is_absolute() @@ -692,6 +770,35 @@ impl PathPrefixPolicy { } } +/// `text` as an object-store store and key segments, if it is a plain object-store URL: a +/// bucket, no credentials, no `%` (percent-encoded text would not be compared the way the +/// store reads it), no query or fragment, and no `.`, `..` or empty key segment (the URL +/// parser would fold them, so the text written would not be the key compared). A trailing +/// `/` is allowed. +fn parse_object_prefix(text: &str) -> Option { + if text.contains(['%', '?', '#']) { + return None; + } + let (_, rest) = text.split_once("://")?; + let key = rest.split_once('/').map_or("", |(_, key)| key); + let key = key.strip_suffix('/').unwrap_or(key); + if !key.is_empty() && key.split('/').any(|s| matches!(s, "" | "." | "..")) { + return None; + } + let url = parse_object_url(text).ok()?; + let segments = url + .key + .as_ref() + .split('/') + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(); + Some(ObjectPrefix { + store: url.store_id, + segments, + }) +} + /// `path` with the symbolic links of its longest existing prefix resolved /// (the rest does not exist yet, so it holds no links). fn resolve_links(path: &Path) -> Option { diff --git a/crates/krishiv-session/tests/path_confinement.rs b/crates/krishiv-session/tests/path_confinement.rs new file mode 100644 index 000000000..d3ac45a40 --- /dev/null +++ b/crates/krishiv-session/tests/path_confinement.rs @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: Apache-2.0 +//! `path_confinement` (P3-T002 acceptance; ADR 0046 §3a): in server mode a path is allowed only +//! under `krishiv.io.local.allowedRoots` or `krishiv.io.objectStore.allowedPrefixes` (default +//! empty), for every role, admin included. The confinement is `PathPrefixPolicy` around +//! `RolePolicy`, so the role still decides inside the lists. +#![cfg(test)] + +use std::sync::Arc; + +use futures::executor::block_on; +use krishiv_common::config::Config; +use krishiv_common::secret::OptionValue; +use krishiv_plan::analyzer::PolicyVersion; +use krishiv_session::auth::{Role, RolePolicy}; +use krishiv_session::{ + AccessDecision, AccessPolicy, AllowAll, PathPrefixPolicy, Principal, PrincipalKind, Privilege, + Securable, Session, +}; + +const DENIED: &str = "KRISHIV_INSUFFICIENT_PERMISSIONS"; +const ROLES: [Role; 3] = [Role::Viewer, Role::Operator, Role::Admin]; + +fn config(pairs: &[(&str, &str)]) -> Config { + Config::from_pairs(pairs.iter().copied()).unwrap().0 +} + +/// The server-mode policy of a principal with `role` under `config`. +fn policy(config: &Config, role: Role) -> PathPrefixPolicy { + let roles = RolePolicy::new(Some(role), Role::Admin, PolicyVersion(1)); + PathPrefixPolicy::from_config(config, Arc::new(roles)).unwrap() +} + +fn session(config: &Config, role: Role) -> Session { + Session::builder() + .principal(Principal::new(role.as_str(), PrincipalKind::User)) + .access_policy(Arc::new(policy(config, role))) + .build() + .unwrap() +} + +fn run(s: &Session, sql: &str) -> Result<(), String> { + block_on(async { + let df = s.sql(sql).await?; + df.collect().await.map(|_| ()) + }) + .map_err(|e| e.to_string()) +} + +fn read(path: &str) -> String { + format!("SELECT * FROM csv.`{path}`") +} + +/// The policy's decision on `privilege` over the path `path`. +fn decide(policy: &PathPrefixPolicy, path: &str, privilege: Privilege) -> bool { + let principal = Principal::new("p", PrincipalKind::User); + let securable = Securable::Path(OptionValue::from_user("path", path)); + matches!( + block_on(policy.check(&principal, &securable, privilege)).unwrap(), + AccessDecision::Allow + ) +} + +/// A directory `allowed` holding `a.csv`, and a sibling `secret` holding `s.csv`. +fn dirs() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let allowed = tmp.path().join("allowed"); + let secret = tmp.path().join("secret"); + std::fs::create_dir(&allowed).unwrap(); + std::fs::create_dir(&secret).unwrap(); + std::fs::write(allowed.join("a.csv"), "1\n2\n").unwrap(); + std::fs::write(secret.join("s.csv"), "3\n").unwrap(); + (tmp, allowed, secret) +} + +#[test] +fn path_confinement() { + let (_tmp, allowed, secret) = dirs(); + let conf = config(&[ + ("krishiv.io.local.allowedRoots", allowed.to_str().unwrap()), + ( + "krishiv.io.objectStore.allowedPrefixes", + "s3://lake/data, gs://shared", + ), + ]); + let inside = allowed.join("a.csv"); + let escape = allowed.join("..").join("secret").join("s.csv"); + let link = allowed.join("link"); + std::os::unix::fs::symlink(&secret, &link).unwrap(); + let through_link = link.join("s.csv"); + for role in ROLES { + let s = session(&conf, role); + // Outside the roots: refused for every role, admin included. + for path in [secret.join("s.csv"), escape.clone(), through_link.clone()] { + let e = run(&s, &read(path.to_str().unwrap())).unwrap_err(); + assert!(e.contains(DENIED), "{role} reading {}: {e}", path.display()); + } + for path in [ + "s3://lake/data2/x.csv", + "s3://lake/x.csv", + "s3://other/data/x.csv", + "gs://other/x.csv", + "https://example.com/x.csv", + ] { + let e = run(&s, &read(path)).unwrap_err(); + assert!(e.contains(DENIED), "{role} reading {path}: {e}"); + } + // Inside the roots the role decides: a viewer reads no path data (ADR 0046 §3). + let inside = run(&s, &read(inside.to_str().unwrap())); + match role { + Role::Viewer => assert!(inside.unwrap_err().contains(DENIED)), + _ => inside.unwrap_or_else(|e| panic!("{role} reading inside the root: {e}")), + } + // Object-store prefixes, decided without touching the store. + let p = policy(&conf, role); + let operator = role >= Role::Operator; + for path in [ + "s3://lake/data/x.csv", + "s3a://lake/data/y/z.parquet", + "s3://lake/data/*.csv", + "gs://shared/any/key", + ] { + assert_eq!( + decide(&p, path, Privilege::Select), + operator, + "{role}: {path}" + ); + } + for path in [ + "s3://lake/data2/x", + "s3://lake/dat*/x", + "s3://lake/data/../x", + "s3://lake/data/%2e%2e/x", + "s3://lake//data/x", + "s3://other/data/x", + "gs://shared2/x", + "file:relative", + ] { + assert!(!decide(&p, path, Privilege::Select), "{role}: {path}"); + } + } +} + +#[test] +fn defaults_allow_no_path_for_any_role() { + let (_tmp, allowed, _) = dirs(); + let conf = config(&[]); + for role in ROLES { + let s = session(&conf, role); + let e = run(&s, &read(allowed.join("a.csv").to_str().unwrap())).unwrap_err(); + assert!(e.contains(DENIED), "{role}: {e}"); + let p = policy(&conf, role); + assert!(!decide(&p, "s3://lake/data/x", Privilege::Select)); + // Statements that read no path keep working. + if role >= Role::Operator { + run(&s, "SELECT 1").unwrap(); + } + } +} + +#[test] +fn a_bad_prefix_or_root_refuses_startup() { + for prefix in [ + "s3://", + "s3:///data", + "s3://key:secret@lake/data", + "s3://lake/../other", + "s3://lake/./data", + "s3://lake//data", + "s3://lake/%2e%2e", + "s3://lake/data?x=1", + "file:/tmp", + "/tmp", + "ftp://host/x", + ] { + let e = PathPrefixPolicy::new(Vec::<&str>::new(), Arc::new(AllowAll)) + .unwrap() + .with_object_prefixes([prefix]) + .unwrap_err(); + assert_eq!( + e.error_class().name(), + "KRISHIV_INVALID_CONFIG_VALUE", + "{prefix}" + ); + } + let conf = config(&[("krishiv.io.local.allowedRoots", "/no/such/dir/krishiv")]); + let e = PathPrefixPolicy::from_config(&conf, Arc::new(AllowAll)).unwrap_err(); + assert_eq!(e.error_class().name(), "PATH_NOT_FOUND"); + // A whole bucket and a trailing slash are fine. + let p = PathPrefixPolicy::new(Vec::<&str>::new(), Arc::new(AllowAll)) + .unwrap() + .with_object_prefixes(["s3://lake", "gs://shared/data/"]) + .unwrap(); + assert!(decide(&p, "s3://lake/x", Privilege::Select)); + assert!(decide(&p, "gs://shared/data/x", Privilege::Modify)); + assert!(!decide(&p, "gs://shared/datax", Privilege::Select)); +} From 07d1beabe80bad6e8f69d281b67cb64203313a7a Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:25:42 +0000 Subject: [PATCH 17/47] =?UTF-8?q?session,=20io:=20path=20confinement=20rev?= =?UTF-8?q?iew=20fixes=20(P3-T002,=20ADR=200046=20=C2=A73a):=20Azure=20pre?= =?UTF-8?q?fixes=20name=20their=20account,=20listings=20never=20follow=20a?= =?UTF-8?q?=20link=20out=20of=20the=20allowed=20roots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus diff review ops/reviews/2026-11-13-opus-A-t002-path-confinement-diff.md: - B1: an Azure prefix must carry its account host (abfss://c@acct.dfs.core.windows.net/p), so an account-less path, whose account would come from the client's fs.azure.account.name, never matches. Azure paths arrive typed as URLs with credentials (container@host); the policy now compares them (Azure only; container:secret@ is refused by the parser), where before every Azure path was denied. - B2: SettledLocalFileSystem::confined(roots): a glob or directory listing that meets a link (directory, partition directory or file) out of every root fails. The runtime uses it whenever krishiv.io.local.allowedRoots is set. - M2: prefixes with glob characters are refused at startup. - N2/N3: a bad root is KRISHIV_INVALID_CONFIG_VALUE naming the key; roots must be absolute existing directories (allowed_roots, shared by policy and runtime). - N1, N5, N6: edge tests (s3a/s3, scheme case, gs bucket names, blank entries), error text, rustdoc wrap. Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-io/src/io/local_listing.rs | 85 ++++++++++- crates/krishiv-session/src/access.rs | 144 +++++++++++++----- crates/krishiv-session/src/runtime.rs | 19 ++- .../krishiv-session/tests/path_confinement.rs | 128 +++++++++++++++- 4 files changed, 325 insertions(+), 51 deletions(-) diff --git a/crates/krishiv-io/src/io/local_listing.rs b/crates/krishiv-io/src/io/local_listing.rs index fd5e51680..2bfbf21d7 100644 --- a/crates/krishiv-io/src/io/local_listing.rs +++ b/crates/krishiv-io/src/io/local_listing.rs @@ -53,6 +53,9 @@ const STORE: &str = "SettledLocalFileSystem"; #[derive(Debug, Default)] pub struct SettledLocalFileSystem { inner: LocalFileSystem, + /// Server-mode confinement (ADR 0046 §3a): a listing that meets a link whose target lies + /// outside every one of these (canonical) directories fails instead of following it. + roots: Option>, } impl SettledLocalFileSystem { @@ -61,6 +64,18 @@ impl SettledLocalFileSystem { pub fn new() -> Self { Self::default() } + + /// A store whose listings never follow a link out of `roots` (canonical directories, as + /// `std::fs::canonicalize` gives them): a glob or directory read below a root that meets + /// such a link fails with a permission error, so a link planted inside a root cannot + /// expose files outside it. Paths themselves are checked by the access policy. + #[must_use] + pub fn confined(roots: Vec) -> Self { + Self { + inner: LocalFileSystem::default(), + roots: Some(roots.into()), + } + } } impl fmt::Display for SettledLocalFileSystem { @@ -128,6 +143,7 @@ fn gone(e: &std::io::Error) -> bool { /// scheduler retries after a wait. `cancelled` ends the walk with an error too. fn walk_with( root: &FsPath, + roots: Option<&[PathBuf]>, cancelled: &AtomicBool, mut between: impl FnMut(), ) -> StoreResult> { @@ -135,7 +151,7 @@ fn walk_with( let mut vanished = false; for _ in 0..SETTLE_PASSES { out.clear(); - let pass = walk_once(root, cancelled, &mut between, &mut out)?; + let pass = walk_once(root, roots, cancelled, &mut between, &mut out)?; if !pass.vanished && !pass.moved { return Ok(out); } @@ -158,6 +174,7 @@ fn walk_with( /// left out, and a directory that cannot be read is an error. fn walk_once( root: &FsPath, + roots: Option<&[PathBuf]>, cancelled: &AtomicBool, between: &mut impl FnMut(), out: &mut Vec, @@ -228,6 +245,16 @@ fn walk_once( Err(e) if file_type.is_dir() => return Err(io_err(e)), Err(_) => continue, }; + if file_type.is_symlink() + && let Some(roots) = roots + && !std::fs::canonicalize(&path) + .is_ok_and(|t| roots.iter().any(|r| t.starts_with(r))) + { + return Err(io_err(std::io::Error::new( + ErrorKind::PermissionDenied, + format!("a link out of the allowed roots at {}", path.display()), + ))); + } if m.is_dir() { if file_type.is_symlink() && above.contains(&id_of(&m)) { return Err(io_err(std::io::Error::other(format!( @@ -414,7 +441,8 @@ impl ObjectStore for SettledLocalFileSystem { }; let cancelled = Arc::new(AtomicBool::new(false)); let guard = CancelOnDrop(Arc::clone(&cancelled)); - let walk = move || walk_with(&root, &cancelled, || {}); + let roots = self.roots.clone(); + let walk = move || walk_with(&root, roots.as_deref(), &cancelled, || {}); futures::stream::once(async move { let _guard = guard; match tokio::runtime::Handle::try_current() { @@ -463,7 +491,7 @@ mod tests { use super::*; fn names(dir: &FsPath, between: impl FnMut()) -> Vec { - let mut out: Vec = walk_with(dir, &AtomicBool::new(false), between) + let mut out: Vec = walk_with(dir, None, &AtomicBool::new(false), between) .unwrap() .into_iter() .map(|m| m.location.filename().unwrap().to_owned()) @@ -624,7 +652,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); std::fs::write(tmp.path().join("f0"), b"1").unwrap(); let mut n = 0; - let e = walk_with(tmp.path(), &AtomicBool::new(false), || { + let e = walk_with(tmp.path(), None, &AtomicBool::new(false), || { std::fs::write(tmp.path().join(format!("f{}", n + 1)), b"1").unwrap(); std::fs::remove_file(tmp.path().join(format!("f{n}"))).unwrap(); n += 1; @@ -641,7 +669,7 @@ mod tests { std::fs::write(tmp.path().join("a"), b"1").unwrap(); let cancelled = Arc::new(AtomicBool::new(false)); drop(CancelOnDrop(Arc::clone(&cancelled))); - let e = walk_with(tmp.path(), &cancelled, || {}).unwrap_err(); + let e = walk_with(tmp.path(), None, &cancelled, || {}).unwrap_err(); assert!(e.to_string().contains("dropped"), "{e}"); } @@ -650,7 +678,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); std::fs::write(tmp.path().join("f"), b"1").unwrap(); std::os::unix::fs::symlink(tmp.path().join("f/x"), tmp.path().join("l")).unwrap(); - walk_with(tmp.path(), &AtomicBool::new(false), || {}).unwrap_err(); + walk_with(tmp.path(), None, &AtomicBool::new(false), || {}).unwrap_err(); } #[test] @@ -677,7 +705,7 @@ mod tests { // A directory is an error. std::fs::create_dir(shut.join("sub")).unwrap(); mode(0o444); - let walked = walk_with(tmp.path(), &AtomicBool::new(false), || {}); + let walked = walk_with(tmp.path(), None, &AtomicBool::new(false), || {}); mode(0o755); walked.unwrap_err(); } @@ -712,7 +740,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); std::fs::create_dir(tmp.path().join("d")).unwrap(); std::os::unix::fs::symlink(tmp.path(), tmp.path().join("d/up")).unwrap(); - let e = walk_with(tmp.path(), &AtomicBool::new(false), || {}).unwrap_err(); + let e = walk_with(tmp.path(), None, &AtomicBool::new(false), || {}).unwrap_err(); assert!(e.to_string().contains("a link back"), "{e}"); } @@ -940,4 +968,45 @@ mod range_read_tests { .unwrap(); assert_eq!(&got[1][..], &data[4..8]); } + + /// A confined store's listing fails at a link out of its roots (file or directory, met + /// directly or below a directory) and follows links that stay inside them. + #[cfg(unix)] + #[test] + fn a_confined_walk_refuses_links_out_of_its_roots() { + let tmp = tempfile::tempdir().unwrap(); + let base = std::fs::canonicalize(tmp.path()).unwrap(); + let root = base.join("root"); + let outside = base.join("outside"); + std::fs::create_dir_all(root.join("inner")).unwrap(); + std::fs::create_dir_all(outside.join("k=1")).unwrap(); + std::fs::write(root.join("inner/a.csv"), "1").unwrap(); + std::fs::write(outside.join("k=1/s.csv"), "3").unwrap(); + std::os::unix::fs::symlink(root.join("inner"), root.join("in_link")).unwrap(); + let roots = [root.clone()]; + let walk = |dir: &FsPath| walk_with(dir, Some(&roots), &AtomicBool::new(false), || {}); + assert_eq!( + walk(&root).unwrap().len(), + 2, + "a link inside the root is followed" + ); + + std::os::unix::fs::symlink(outside.join("k=1"), root.join("k=1")).unwrap(); + let e = walk(&root).unwrap_err(); + assert!( + e.to_string().contains("a link out of the allowed roots"), + "{e}" + ); + std::fs::remove_file(root.join("k=1")).unwrap(); + + std::os::unix::fs::symlink(outside.join("k=1/s.csv"), root.join("inner/s.csv")).unwrap(); + assert!(walk(&root).is_err(), "a file link out of the root"); + assert!( + walk(&root.join("inner")).is_err(), + "met below the listed directory too" + ); + // Unconfined, the same tree lists the linked file. + let all = walk_with(&root, None, &AtomicBool::new(false), || {}).unwrap(); + assert_eq!(all.len(), 4); + } } diff --git a/crates/krishiv-session/src/access.rs b/crates/krishiv-session/src/access.rs index 024454a78..85c290f45 100644 --- a/crates/krishiv-session/src/access.rs +++ b/crates/krishiv-session/src/access.rs @@ -622,9 +622,12 @@ pub(crate) fn still_physical(session: &Session, at: &LocatedPath) -> Result<(), /// links in the part of it that exists, against the roots with their links /// resolved. Object-store paths are allowed only under the prefixes of /// [`Self::with_object_prefixes`] (none by default). Anything else is -/// denied: other schemes, paths with credentials, any path containing `%` (percent-encoded text would -/// not be decoded the way the comparison reads it, so even a literal `%` -/// in a file name is refused) and relative or unresolved paths. Session +/// denied: other schemes, paths with credentials, any path containing `%` +/// (percent-encoded text would not be decoded the way the comparison reads +/// it, so even a literal `%` in a file name is refused) and relative or +/// unresolved paths. A glob or directory read is checked as written; the +/// runtime's local store refuses a listing that follows a link out of +/// `krishiv.io.local.allowedRoots` (set it on the runtime too). Session /// writes go to the resolved directory, which is opened once and then used /// by descriptor (no name is resolved through a link while the write runs), /// and fail if a link is swapped into it before the write begins or commits. @@ -693,29 +696,43 @@ impl PathPrefixPolicy { } /// Also allows object-store paths under `prefixes` (`s3://bucket/prefix`, `gs://bucket`, - /// `abfss://container@account/prefix`): a path is allowed when it names the same store - /// (`s3` and `s3a` are one) and its key starts with the prefix's whole segments, so - /// `s3://b/data` allows `s3://b/data/x` but not `s3://b/data2` (ADR 0046 §3a). + /// `abfss://container@account.dfs.core.windows.net/prefix`): a path is allowed when it + /// names the same store (`s3` and `s3a` are one; for Azure the same container in the same + /// account) and its key starts with the prefix's whole segments, so `s3://b/data` allows + /// `s3://b/data/x` but not `s3://b/data2` (ADR 0046 §3a). An Azure path without an + /// account host is never allowed, since its account would come from the client's settings. /// /// # Errors /// `KRISHIV_INVALID_CONFIG_VALUE` for a prefix that is not an object-store URL with a - /// bucket, or that holds credentials, `%`, a query or fragment, or a `.`, `..` or empty - /// segment. + /// bucket, an Azure prefix without an `account.`, or one that holds credentials, + /// `%`, a query or fragment, a glob character, or a `.`, `..` or empty segment. pub fn with_object_prefixes>( mut self, prefixes: impl IntoIterator, ) -> Result { for prefix in prefixes { let prefix = prefix.as_ref(); - let parsed = parse_object_prefix(prefix).ok_or_else(|| { - KrishivError::from(KrishivInvalidConfigValue { - value: format!("'{prefix}'"), - key: format!("\"{}\"", IO_OBJECT_STORE_ALLOWED_PREFIXES.key), - reason: "expected an object-store prefix such as s3://bucket/prefix, without \ - credentials, `%`, a query, or `.`, `..` or empty segments" - .to_owned(), + let parsed = parse_object_prefix(prefix) + .filter(|_| !prefix.contains(['*', '?', '[', ']', '{', '}'])) + // An Azure prefix names its account: without one, the store would take the + // account from the client's `fs.azure.account.name`. + .filter(|_| { + parse_object_url(prefix).is_ok_and(|u| { + u.scheme != krishiv_io::ObjectScheme::Azure || u.account.is_some() + }) }) - })?; + .ok_or_else(|| { + KrishivError::from(KrishivInvalidConfigValue { + value: format!("'{prefix}'"), + key: format!("\"{}\"", IO_OBJECT_STORE_ALLOWED_PREFIXES.key), + reason: + "expected an object-store prefix such as s3://bucket/prefix (Azure: \ + abfss://container@account.dfs.core.windows.net/prefix), without \ + credentials, `%`, a query or fragment, glob characters, or `.`, `..` \ + or empty segments" + .to_owned(), + }) + })?; self.prefixes.push(parsed); } Ok(self) @@ -727,36 +744,39 @@ impl PathPrefixPolicy { /// nothing by path), and nothing else, for every role. /// /// # Errors - /// `PATH_NOT_FOUND` for a root that does not exist; `KRISHIV_INVALID_CONFIG_VALUE` for a - /// prefix [`Self::with_object_prefixes`] refuses. + /// `KRISHIV_INVALID_CONFIG_VALUE` for a root that is not an absolute path to an existing + /// directory, or a prefix [`Self::with_object_prefixes`] refuses. pub fn from_config( config: &Config, inner: Arc, ) -> Result { - let list = |entry| match config.get(entry) { - Some(ConfigValue::String(raw)) => raw - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_owned) - .collect(), - _ => Vec::new(), - }; - Self::new(list(&IO_LOCAL_ALLOWED_ROOTS), inner)? - .with_object_prefixes(list(&IO_OBJECT_STORE_ALLOWED_PREFIXES)) + Self { + roots: allowed_roots(config)?, + prefixes: Vec::new(), + inner, + } + .with_object_prefixes(config_list(config, &IO_OBJECT_STORE_ALLOWED_PREFIXES)) } /// Whether the qualified path `path` lies under a root or an object-store prefix. fn allows(&self, path: &OptionValue) -> bool { - let Some(text) = path.as_plain() else { - return false; + let text = match path { + OptionValue::Plain(text) => text.as_str(), + // `container@account.host` reads as user info, so an Azure path arrives typed as a + // URL with credentials; Azure parsing takes it as container and account and refuses + // `user:secret@` forms, so only Azure URLs are compared, and the text never leaves + // this check. + OptionValue::Url(url) => { + let exposed = url.expose_secret(); + let text = exposed.expose_secret(); + return parse_object_url(text) + .is_ok_and(|u| u.scheme == krishiv_io::ObjectScheme::Azure) + && self.allows_object(text); + } + OptionValue::Secret(_) => return false, }; let Some(local) = text.strip_prefix("file:") else { - return parse_object_prefix(text).is_some_and(|p| { - self.prefixes - .iter() - .any(|a| a.store == p.store && p.segments.starts_with(&a.segments)) - }); + return self.allows_object(text); }; let local = Path::new(local); let normal = local.is_absolute() @@ -770,6 +790,58 @@ impl PathPrefixPolicy { } } +impl PathPrefixPolicy { + /// Whether the object-store URL `text` lies under one of the prefixes. + fn allows_object(&self, text: &str) -> bool { + parse_object_prefix(text).is_some_and(|p| { + self.prefixes + .iter() + .any(|a| a.store == p.store && p.segments.starts_with(&a.segments)) + }) + } +} + +/// The comma-separated entries of the string setting `entry` (blank entries dropped). +fn config_list(config: &Config, entry: &krishiv_common::config::ConfigEntry) -> Vec { + match config.get(entry) { + Some(ConfigValue::String(raw)) => raw + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(), + _ => Vec::new(), + } +} + +/// `krishiv.io.local.allowedRoots`, canonical (links resolved); empty when unset. The policy +/// checks paths against them and the runtime's local store refuses listings that follow a +/// link out of them (ADR 0046 §3a). +/// +/// # Errors +/// `KRISHIV_INVALID_CONFIG_VALUE` naming the key for a root that is not an absolute path to +/// an existing directory. +pub(crate) fn allowed_roots(config: &Config) -> Result, KrishivError> { + config_list(config, &IO_LOCAL_ALLOWED_ROOTS) + .into_iter() + .map(|root| { + let path = Path::new(&root); + std::fs::canonicalize(path) + .ok() + .filter(|c| path.is_absolute() && c.is_dir()) + .ok_or_else(|| { + KrishivError::from(KrishivInvalidConfigValue { + value: format!("'{root}'"), + key: format!("\"{}\"", IO_LOCAL_ALLOWED_ROOTS.key), + reason: "expected absolute paths of existing directories, comma \ + separated" + .to_owned(), + }) + }) + }) + .collect() +} + /// `text` as an object-store store and key segments, if it is a plain object-store URL: a /// bucket, no credentials, no `%` (percent-encoded text would not be compared the way the /// store reads it), no query or fragment, and no `.`, `..` or empty key segment (the URL diff --git a/crates/krishiv-session/src/runtime.rs b/crates/krishiv-session/src/runtime.rs index ddf5dc623..a8963a791 100644 --- a/crates/krishiv-session/src/runtime.rs +++ b/crates/krishiv-session/src/runtime.rs @@ -222,11 +222,22 @@ impl EngineRuntime { let io = IoScheduler::with_runtime(IoConfig::from_config(&config), executor.io_handle().clone()); io.attach_memory(&(Arc::::clone(&memory_tree) as Arc)); + // With `krishiv.io.local.allowedRoots` set (server mode), listings never follow a link + // out of the roots (ADR 0046 §3a): the policy checks the paths as written, the store + // what a glob or directory read finds below them. #[cfg(unix)] - io.register_whole_listing_store( - StoreId::new(crate::session::LOCAL_STORE), - Arc::new(krishiv_io::SettledLocalFileSystem::new()) as Arc, - ); + { + let roots = crate::access::allowed_roots(&config)?; + let local = if roots.is_empty() { + krishiv_io::SettledLocalFileSystem::new() + } else { + krishiv_io::SettledLocalFileSystem::confined(roots) + }; + io.register_whole_listing_store( + StoreId::new(crate::session::LOCAL_STORE), + Arc::new(local) as Arc, + ); + } #[cfg(not(unix))] io.register_store( StoreId::new(crate::session::LOCAL_STORE), diff --git a/crates/krishiv-session/tests/path_confinement.rs b/crates/krishiv-session/tests/path_confinement.rs index d3ac45a40..57cce0323 100644 --- a/crates/krishiv-session/tests/path_confinement.rs +++ b/crates/krishiv-session/tests/path_confinement.rs @@ -171,6 +171,14 @@ fn a_bad_prefix_or_root_refuses_startup() { "file:/tmp", "/tmp", "ftp://host/x", + // Glob characters would match more than whole segments. + "s3://lake/da*", + "s3://lake/{data,secret}", + "s3://lake/data[0]", + // An Azure prefix names its account host. + "abfss://exports/public", + "abfss://exports@lakeacct/public", + "az://exports/public", ] { let e = PathPrefixPolicy::new(Vec::<&str>::new(), Arc::new(AllowAll)) .unwrap() @@ -182,9 +190,33 @@ fn a_bad_prefix_or_root_refuses_startup() { "{prefix}" ); } - let conf = config(&[("krishiv.io.local.allowedRoots", "/no/such/dir/krishiv")]); - let e = PathPrefixPolicy::from_config(&conf, Arc::new(AllowAll)).unwrap_err(); - assert_eq!(e.error_class().name(), "PATH_NOT_FOUND"); + let file = tempfile::NamedTempFile::new().unwrap(); + for root in [ + "/no/such/dir/krishiv", + "relative/dir", + file.path().to_str().unwrap(), + ] { + let conf = config(&[("krishiv.io.local.allowedRoots", root)]); + let e = PathPrefixPolicy::from_config(&conf, Arc::new(AllowAll)).unwrap_err(); + assert_eq!( + e.error_class().name(), + "KRISHIV_INVALID_CONFIG_VALUE", + "{root}" + ); + assert!( + e.to_string().contains("krishiv.io.local.allowedRoots"), + "{e}" + ); + let e = Session::builder() + .config("krishiv.io.local.allowedRoots", root) + .build() + .unwrap_err(); + assert_eq!( + e.error_class().name(), + "KRISHIV_INVALID_CONFIG_VALUE", + "{root}" + ); + } // A whole bucket and a trailing slash are fine. let p = PathPrefixPolicy::new(Vec::<&str>::new(), Arc::new(AllowAll)) .unwrap() @@ -194,3 +226,93 @@ fn a_bad_prefix_or_root_refuses_startup() { assert!(decide(&p, "gs://shared/data/x", Privilege::Modify)); assert!(!decide(&p, "gs://shared/datax", Privilege::Select)); } + +/// B1 of the T002 confinement review: an Azure prefix allows its container in its account only; +/// a path without an account host (whose account would come from the client's +/// `fs.azure.account.name`) or in another account is refused. +#[test] +fn azure_prefixes_hold_their_account() { + let p = PathPrefixPolicy::new(Vec::<&str>::new(), Arc::new(AllowAll)) + .unwrap() + .with_object_prefixes(["abfss://exports@lakeacct.dfs.core.windows.net/public"]) + .unwrap(); + assert!(decide( + &p, + "abfss://exports@lakeacct.dfs.core.windows.net/public/t/x.parquet", + Privilege::Select + )); + for path in [ + "abfss://exports/public/x.parquet", + "az://exports/public/x.parquet", + "abfss://exports:key@lakeacct.dfs.core.windows.net/public/x.parquet", + "abfss://exports@hracct.dfs.core.windows.net/public/x.parquet", + "abfss://other@lakeacct.dfs.core.windows.net/public/x.parquet", + "abfss://exports@lakeacct.dfs.core.windows.net/private/x.parquet", + ] { + assert!(!decide(&p, path, Privilege::Select), "{path}"); + } +} + +/// More prefix shapes: `s3a` and `s3` are one store either way, the scheme's case does not +/// matter, a bucket name is whole, blank list entries are dropped. +#[test] +fn prefix_matching_edges() { + let conf = config(&[( + "krishiv.io.objectStore.allowedPrefixes", + " , s3a://lake/data ,gs://shared, ", + )]); + let p = policy(&conf, Role::Admin); + assert!(decide(&p, "s3://lake/data/x", Privilege::Select)); + assert!(decide(&p, "S3://lake/data/x", Privilege::Select)); + assert!(decide(&p, "s3a://lake/data/x", Privilege::Modify)); + assert!(decide(&p, "gs://shared/x", Privilege::Create)); + assert!(!decide(&p, "gs://shared.other/x", Privilege::Select)); + assert!(!decide(&p, "s3://lake/data2/x", Privilege::Select)); +} + +/// B2 of the T002 confinement review: a glob or directory read below a root never follows a +/// link out of it, whether the link is a directory, a partition directory or a file. +#[cfg(unix)] +#[test] +fn listings_never_follow_a_link_out_of_a_root() { + let (_tmp, allowed, secret) = dirs(); + let root = allowed.to_str().unwrap(); + let conf = config(&[("krishiv.io.local.allowedRoots", root)]); + let s = Session::builder() + .config("krishiv.io.local.allowedRoots", root) + .principal(Principal::new("admin", PrincipalKind::User)) + .access_policy(Arc::new(policy(&conf, Role::Admin))) + .build() + .unwrap(); + assert!(run(&s, &read(&format!("{root}/a.csv"))).is_ok()); + + std::os::unix::fs::symlink(&secret, allowed.join("link")).unwrap(); + for path in [ + format!("{root}/link/s.csv"), + format!("{root}/*/s.csv"), + format!("{root}/{{link,x}}/s.csv"), + format!("{root}/l*"), + ] { + let e = run(&s, &read(&path)).unwrap_err(); + assert!( + e.contains(DENIED) || e.contains("a link out of the allowed roots"), + "{path}: {e}" + ); + } + std::fs::remove_file(allowed.join("link")).unwrap(); + + // A partition-directory link: a plain directory read of the root. + std::fs::create_dir(secret.join("k=1")).unwrap(); + std::fs::write(secret.join("k=1/s.csv"), "4\n").unwrap(); + let table = allowed.join("t"); + std::fs::create_dir(&table).unwrap(); + std::os::unix::fs::symlink(secret.join("k=1"), table.join("k=1")).unwrap(); + let e = run(&s, &read(table.to_str().unwrap())).unwrap_err(); + assert!(e.contains("a link out of the allowed roots"), "{e}"); + std::fs::remove_file(table.join("k=1")).unwrap(); + + // A file link out of the root. + std::os::unix::fs::symlink(secret.join("s.csv"), table.join("s.csv")).unwrap(); + let e = run(&s, &read(table.to_str().unwrap())).unwrap_err(); + assert!(e.contains("a link out of the allowed roots"), "{e}"); +} From 40e7b8821420e180665ceac9f2ce41c2d60cba2f Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:34:07 +0000 Subject: [PATCH 18/47] store, io, plan, session, common: server-mode egress and credential defaults (P3-T067b, PR-14b) - A store endpoint (krishiv_store::ENDPOINT_KEYS: fs.s3a.endpoint, fs.gs.storage.root.url, fs.gs.endpoint, fs.azure.endpoint, fs.hf.endpoint) needs Manage on System("cluster"), admin only in server mode: per read (bind_files, before anything is listed), per write (authorize_write_path, before anything is written), on SQL SET (before the value is stored) and, for spark.conf.set, on every query while the session's value differs from the one it was built with (analyze_in). - Ambient credentials and http(s):// locations reach only krishiv.io.objectStore.allowedPrefixes (the T002 confinement); listing an http(s) prefix is the enable. - tests/server_egress.rs: one test per default; embedded use unchanged. - Config docs: the allowedPrefixes and allowedRoots entries describe both. Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-common/src/config/registry.rs | 28 +- crates/krishiv-io/src/io/mod.rs | 10 +- crates/krishiv-io/src/io/stores.rs | 9 +- crates/krishiv-io/src/lib.rs | 8 +- crates/krishiv-plan/src/analyzer/bind.rs | 6 + crates/krishiv-session/src/command.rs | 10 + crates/krishiv-session/src/conf.rs | 17 + crates/krishiv-session/src/session.rs | 4 + crates/krishiv-session/src/session/ops.rs | 29 ++ crates/krishiv-session/tests/server_egress.rs | 314 ++++++++++++++++++ crates/krishiv-store/src/lib.rs | 9 +- crates/krishiv-store/src/stores.rs | 18 + 12 files changed, 434 insertions(+), 28 deletions(-) create mode 100644 crates/krishiv-session/tests/server_egress.rs diff --git a/crates/krishiv-common/src/config/registry.rs b/crates/krishiv-common/src/config/registry.rs index ac376c389..807cc25ce 100644 --- a/crates/krishiv-common/src/config/registry.rs +++ b/crates/krishiv-common/src/config/registry.rs @@ -1089,11 +1089,13 @@ pub static IO_LOCAL_ALLOWED_ROOTS: ConfigEntry = ConfigEntry { key: "krishiv.io.local.allowedRoots", value_type: ValueType::String, default: None, - doc: "Server mode (authentication configured) only: a comma-separated list of existing local \ - directories that reads and writes by path may use, for every role, admin included \ - (ADR 0046 §3a). A path is compared after `.`, `..` and symbolic links are resolved. \ - Unset or empty: no local path is allowed. Embedded use and a server without \ - authentication do not read it.", + doc: "Server mode (authentication configured): a comma-separated list of absolute paths of \ + existing local directories (a path holding a comma cannot be listed) that reads and \ + writes by path may use, for every role, admin included (ADR 0046 §3a). A path is \ + compared after `.`, `..` and symbolic links are resolved, and a glob or directory \ + read below a root fails at a link out of every root. Unset or empty: no local path is \ + allowed in server mode. Embedded use is not confined by path; set there, the key only \ + makes listings refuse links out of the roots.", scope: Scope::Cluster, settable_by: SettableBy::Startup, version_added: "0.2.0", @@ -1106,12 +1108,16 @@ pub static IO_OBJECT_STORE_ALLOWED_PREFIXES: ConfigEntry = ConfigEntry { value_type: ValueType::String, default: None, doc: "Server mode (authentication configured) only: a comma-separated list of object-store \ - prefixes (`s3://bucket/prefix`, `gs://bucket`, `abfss://container@account/prefix`) that \ - reads and writes by path may use, for every role, admin included (ADR 0046 §3a). A \ - prefix matches whole key segments (`s3://b/data` does not match `s3://b/data2`); `s3` \ - and `s3a` name the same store. It is also the one opt-in for the server's own \ - (ambient) credentials. Unset or empty: no object-store path is allowed. Embedded use \ - and a server without authentication do not read it.", + prefixes (`s3://bucket/prefix`, `gs://bucket`, \ + `abfss://container@account.dfs.core.windows.net/prefix`; an Azure prefix names its \ + account host) that reads and writes by path may use, for every role, admin included \ + (ADR 0046 §3a). A prefix matches whole key segments (`s3://b/data` does not match \ + `s3://b/data2`) and holds no glob character; `s3` and `s3a` name the same store; a \ + path holding `%`, `?` or `#` is never allowed, so `?` globs are refused. It is the one \ + opt-in for the server's own (ambient) credentials and for `http(s)://` locations, which \ + are refused unless listed; a store endpoint option (`fs.s3a.endpoint` and the like, per \ + read or set in a session) needs admin (P3-T067b). Unset or empty: no object-store path \ + is allowed. Embedded use and a server without authentication do not read it.", scope: Scope::Cluster, settable_by: SettableBy::Startup, version_added: "0.2.0", diff --git a/crates/krishiv-io/src/io/mod.rs b/crates/krishiv-io/src/io/mod.rs index 36483e569..bf52543a3 100644 --- a/crates/krishiv-io/src/io/mod.rs +++ b/crates/krishiv-io/src/io/mod.rs @@ -72,11 +72,11 @@ pub use self::fault::{FaultStats, FaultStore, ScheduledRequest, TransientError}; pub use self::latency::{LATENCY_BUCKETS, LatencyHistogram}; use self::pace::{Direction, Pacer}; pub use self::stores::{ - AZURE_ACCOUNT_KEY, AZURE_ACCOUNT_NAME, AZURE_ENDPOINT, AZURE_SAS, GCS_ENDPOINT, GCS_KEY, - GCS_KEYFILE, GCS_STORAGE_ROOT_URL, ObjectScheme, OpenedStore, ParsedUrl, S3_ACCESS_KEY, - S3_ENDPOINT, S3_ENDPOINT_REGION, S3_PATH_STYLE_ACCESS, S3_REGION, S3_SECRET_KEY, - S3_SESSION_TOKEN, StoreRegistry, StoreSettings, parse_object_url, query_registry, register, - register_with_options, + AZURE_ACCOUNT_KEY, AZURE_ACCOUNT_NAME, AZURE_ENDPOINT, AZURE_SAS, ENDPOINT_KEYS, GCS_ENDPOINT, + GCS_KEY, GCS_KEYFILE, GCS_STORAGE_ROOT_URL, ObjectScheme, OpenedStore, ParsedUrl, + S3_ACCESS_KEY, S3_ENDPOINT, S3_ENDPOINT_REGION, S3_PATH_STYLE_ACCESS, S3_REGION, S3_SECRET_KEY, + S3_SESSION_TOKEN, StoreRegistry, StoreSettings, is_endpoint_key, parse_object_url, + query_registry, register, register_with_options, }; pub use self::upload::Upload; use crate::error::{ diff --git a/crates/krishiv-io/src/io/stores.rs b/crates/krishiv-io/src/io/stores.rs index 33a044a1d..aa92c5cae 100644 --- a/crates/krishiv-io/src/io/stores.rs +++ b/crates/krishiv-io/src/io/stores.rs @@ -14,10 +14,11 @@ use std::sync::Arc; use krishiv_common::secret::OptionValue; pub(crate) use krishiv_store::is_store_option; pub use krishiv_store::{ - AZURE_ACCOUNT_KEY, AZURE_ACCOUNT_NAME, AZURE_ENDPOINT, AZURE_SAS, GCS_ENDPOINT, GCS_KEY, - GCS_KEYFILE, GCS_STORAGE_ROOT_URL, HF_ENDPOINT, HF_TOKEN, ObjectScheme, OpenedStore, ParsedUrl, - S3_ACCESS_KEY, S3_ENDPOINT, S3_ENDPOINT_REGION, S3_PATH_STYLE_ACCESS, S3_REGION, S3_SECRET_KEY, - S3_SESSION_TOKEN, StoreRegistry, StoreSettings, parse_object_url, + AZURE_ACCOUNT_KEY, AZURE_ACCOUNT_NAME, AZURE_ENDPOINT, AZURE_SAS, ENDPOINT_KEYS, GCS_ENDPOINT, + GCS_KEY, GCS_KEYFILE, GCS_STORAGE_ROOT_URL, HF_ENDPOINT, HF_TOKEN, ObjectScheme, OpenedStore, + ParsedUrl, S3_ACCESS_KEY, S3_ENDPOINT, S3_ENDPOINT_REGION, S3_PATH_STYLE_ACCESS, S3_REGION, + S3_SECRET_KEY, S3_SESSION_TOKEN, StoreRegistry, StoreSettings, is_endpoint_key, + parse_object_url, }; use object_store::path::Path; diff --git a/crates/krishiv-io/src/lib.rs b/crates/krishiv-io/src/lib.rs index 19198b700..45dff1954 100644 --- a/crates/krishiv-io/src/lib.rs +++ b/crates/krishiv-io/src/lib.rs @@ -67,10 +67,10 @@ pub use io::beneath::{AnchorError, BeneathStore}; pub use io::local_listing::SettledLocalFileSystem; pub use io::{ BlockCacheStats, ByteRange, CacheKey, CacheScope, CacheStats, DistinctSample, DistinctSamples, - FileRef, Held, IoConfig, IoContext, IoCounters, IoScheduler, LATENCY_BUCKETS, LatencyHistogram, - ObjectScheme, OpenedStore, ParsedUrl, PinnedStore, PutIfAbsent, SampleKey, SecretResolver, - StoreId, StorePolicy, StoreRegistry, StoreSettings, Tail, VersionToken, parse_object_url, - query_registry, register, register_with_options, + ENDPOINT_KEYS, FileRef, Held, IoConfig, IoContext, IoCounters, IoScheduler, LATENCY_BUCKETS, + LatencyHistogram, ObjectScheme, OpenedStore, ParsedUrl, PinnedStore, PutIfAbsent, SampleKey, + SecretResolver, StoreId, StorePolicy, StoreRegistry, StoreSettings, Tail, VersionToken, + is_endpoint_key, parse_object_url, query_registry, register, register_with_options, }; pub use json::JsonFormat; pub use options::{ diff --git a/crates/krishiv-plan/src/analyzer/bind.rs b/crates/krishiv-plan/src/analyzer/bind.rs index 93cee8b06..03f4dab8f 100644 --- a/crates/krishiv-plan/src/analyzer/bind.rs +++ b/crates/krishiv-plan/src/analyzer/bind.rs @@ -1065,6 +1065,12 @@ async fn bind_files( // before anything is listed or inferred. let scopes = super::access::secret_scopes(options.values().chain(&qualified)); authorize_usage(ctx, b, checked, &scopes, origin).await?; + // An endpoint option points the store, and the credentials it holds, at another host: + // `Manage` on the cluster (admin in server mode, P3-T067b), before anything is listed. + if options.keys().any(|k| krishiv_io::is_endpoint_key(k)) { + let cluster = Securable::System("cluster".to_owned()); + authorize_as(ctx, b, checked, cluster, Privilege::Manage, origin).await?; + } let columns = files .infer(format, &qualified, options) .await diff --git a/crates/krishiv-session/src/command.rs b/crates/krishiv-session/src/command.rs index 8fc798368..e9c325077 100644 --- a/crates/krishiv-session/src/command.rs +++ b/crates/krishiv-session/src/command.rs @@ -156,6 +156,16 @@ impl Session { } .into()); } + // A store endpoint is `Manage` on the cluster (P3-T067b), asked before the + // value is stored, so a refused `SET` leaves the configuration as it was. + if krishiv_io::is_endpoint_key(key) { + self.authorize_endpoints( + krishiv_sql::StatementClass::Session, + &self.state.conf.snapshot(), + std::iter::once(key.as_str()), + ) + .await?; + } // The assignment sets through the session configuration, // so validation and error classes are its own; the row // shows the value as written, as in Spark. diff --git a/crates/krishiv-session/src/conf.rs b/crates/krishiv-session/src/conf.rs index 53fd4df0b..0c43a1c4a 100644 --- a/crates/krishiv-session/src/conf.rs +++ b/crates/krishiv-session/src/conf.rs @@ -117,6 +117,23 @@ impl ConfStore { self.versioned_snapshot().0 } + /// Whether `config` gives a store endpoint key (`krishiv_io::is_endpoint_key`) another value + /// than the configuration the session was built with: the session itself pointed a store at + /// another host (P3-T067b). + pub(crate) fn endpoint_changed(&self, config: &Config) -> bool { + let endpoints = |c: &Config| { + let mut v: Vec<(String, String)> = c + .explicit_unregistered() + .into_iter() + .filter(|(k, _)| krishiv_io::is_endpoint_key(k)) + .map(|(k, v)| (k.to_ascii_lowercase(), v.to_owned())) + .collect(); + v.sort(); + v + }; + endpoints(config) != endpoints(&self.initial) + } + /// The configuration and its version, read together. pub(crate) fn versioned_snapshot(&self) -> (Arc, u64) { let current = self.current.read().unwrap_or_else(PoisonError::into_inner); diff --git a/crates/krishiv-session/src/session.rs b/crates/krishiv-session/src/session.rs index 7a1839ea9..bc05ca699 100644 --- a/crates/krishiv-session/src/session.rs +++ b/crates/krishiv-session/src/session.rs @@ -1259,6 +1259,10 @@ impl Session { env: &QueryEnv, text: Option<&RedactedSql>, ) -> Result { + // A store endpoint this session set sends the server's credentials to another host: + // asked once per analysis, before any table or file is read (P3-T067b). + self.authorize_endpoints(StatementClass::Read, &env.config, std::iter::empty()) + .await?; // Temporary views over queries inline as subqueries, so the // analyzer sees one plan however the view was made. let next_source = || self.next_source_id(); diff --git a/crates/krishiv-session/src/session/ops.rs b/crates/krishiv-session/src/session/ops.rs index 60acdc2d6..ca8334e8f 100644 --- a/crates/krishiv-session/src/session/ops.rs +++ b/crates/krishiv-session/src/session/ops.rs @@ -43,6 +43,12 @@ impl Session { ) .await; crate::access::audit(self, StatementClass::Write, r)?; + self.authorize_endpoints( + StatementClass::Write, + &self.state.conf.snapshot(), + options.keys().map(String::as_str), + ) + .await?; let securable = Securable::Path(OptionValue::from_user("path", at.display.clone())); for privilege in [Privilege::Modify, Privilege::Create] { let r = authorize( @@ -57,6 +63,29 @@ impl Session { Ok(()) } + /// `Manage` on the cluster when `options` or the session's configuration `config` point a + /// store at another host than the session was built with (an endpoint key, P3-T067b): the + /// store would take the server's credentials there. Admin only in server mode; embedded + /// use (`AllowAll`) is unchanged. + pub(crate) async fn authorize_endpoints<'a>( + &self, + class: StatementClass, + config: &krishiv_common::config::Config, + mut options: impl Iterator, + ) -> Result<(), KrishivError> { + if !options.any(krishiv_io::is_endpoint_key) && !self.state.conf.endpoint_changed(config) { + return Ok(()); + } + let r = authorize( + &*self.state.policy, + &self.state.principal, + &Securable::System("cluster".to_owned()), + Privilege::Manage, + ) + .await; + crate::access::audit(self, class, r) + } + /// The file format named `format`, which must be one the engine writes: /// the Spark file sources it does not write fail with /// `KRISHIV_UNSUPPORTED_FEATURE`. diff --git a/crates/krishiv-session/tests/server_egress.rs b/crates/krishiv-session/tests/server_egress.rs new file mode 100644 index 000000000..619fc41d4 --- /dev/null +++ b/crates/krishiv-session/tests/server_egress.rs @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: Apache-2.0 +//! P3-T067b acceptance: the server-mode egress and credential defaults, one test per default. +//! A server session's policy is `PathPrefixPolicy::from_config` around `RolePolicy` (ADR 0046 +//! §3a), so ambient credentials and `http(s)://` locations reach only what +//! `krishiv.io.objectStore.allowedPrefixes` lists, and a store endpoint (per read, per write or +//! set in the session) needs `Manage` on the cluster, which only admin holds. Embedded use +//! (`AllowAll`) is unchanged. +#![cfg(test)] + +use std::sync::Arc; + +use futures::executor::block_on; +use krishiv_common::config::Config; +use krishiv_common::error::KrishivError; +use krishiv_common::secret::OptionValue; +use krishiv_plan::analyzer::{BoxFuture, PolicyVersion}; +use krishiv_session::auth::{Role, RolePolicy}; +use krishiv_session::{ + AccessDecision, AccessPolicy, AllowAll, PathPrefixPolicy, Principal, PrincipalKind, Privilege, + Securable, Session, +}; + +const DENIED: &str = "KRISHIV_INSUFFICIENT_PERMISSIONS"; +const ROLES: [Role; 3] = [Role::Viewer, Role::Operator, Role::Admin]; +const ENDPOINT: &str = "http://10.0.0.1:9000"; + +fn config(pairs: &[(&str, &str)]) -> Config { + Config::from_pairs(pairs.iter().copied()).unwrap().0 +} + +fn policy(config: &Config, role: Role) -> PathPrefixPolicy { + let roles = RolePolicy::new(Some(role), Role::Admin, PolicyVersion(1)); + PathPrefixPolicy::from_config(config, Arc::new(roles)).unwrap() +} + +/// A server session of `role`, local reads confined to `root`, built with `builder` settings. +fn session(root: &std::path::Path, role: Role, builder: &[(&str, &str)]) -> Session { + let conf = config(&[("krishiv.io.local.allowedRoots", root.to_str().unwrap())]); + let mut b = Session::builder() + .principal(Principal::new(role.as_str(), PrincipalKind::User)) + .access_policy(Arc::new(policy(&conf, role))); + for (k, v) in builder { + b = b.config(*k, *v); + } + b.build().unwrap() +} + +/// A directory holding `a.csv` with two rows. +fn data() -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("a.csv"), "1\n2\n").unwrap(); + tmp +} + +fn outcome(r: Result) -> Result { + r.map_err(|e| match e.error_class().name() { + DENIED => DENIED.to_owned(), + _ => e.to_string(), + }) +} + +fn rows(s: &Session, sql: &str) -> Result { + outcome(block_on(async { + let batches = s.sql(sql).await?.collect().await?; + Ok(batches.iter().map(arrow_array::RecordBatch::num_rows).sum()) + })) +} + +fn read_with(s: &Session, path: &str, options: &[(&str, &str)]) -> Result { + let mut reader = s.read().format("csv"); + for (k, v) in options { + reader = reader.option(k, v); + } + let df = reader.load([path]); + outcome(block_on(async { + let batches = df.collect().await?; + Ok(batches.iter().map(arrow_array::RecordBatch::num_rows).sum()) + })) +} + +/// The policy's decision on reading `path`. +fn allows_read(policy: &PathPrefixPolicy, path: &str) -> bool { + let principal = Principal::new("p", PrincipalKind::User); + let securable = Securable::Path(OptionValue::from_user("path", path)); + matches!( + block_on(policy.check(&principal, &securable, Privilege::Select)).unwrap(), + AccessDecision::Allow + ) +} + +/// Ambient (server) object-store credentials are used only under `allowedPrefixes`: a location +/// anywhere else is refused before a store is built, for every role. (Vended catalog +/// credentials, T067, are not in yet.) +#[test] +fn ambient_credentials_reach_only_allowed_prefixes() { + let conf = config(&[("krishiv.io.objectStore.allowedPrefixes", "s3://lake/shared")]); + for role in ROLES { + let p = policy(&conf, role); + for path in [ + "s3://lake/private/t/part-0.parquet", + "s3://lake/sharedx/part-0.parquet", + "s3://other/shared/part-0.parquet", + "gs://lake/shared/part-0.parquet", + ] { + assert!(!allows_read(&p, path), "{role:?} {path}"); + } + // Inside the list the role decides: viewers never read rows. + assert_eq!( + allows_read(&p, "s3a://lake/shared/t/part-0.parquet"), + role != Role::Viewer, + "{role:?}" + ); + } + let tmp = data(); + for role in ROLES { + let s = session(tmp.path(), role, &[]); + assert_eq!( + rows(&s, "SELECT * FROM parquet.`s3://lake/private/t`"), + Err(DENIED.to_owned()), + "{role:?}" + ); + } +} + +/// A store endpoint in the read options is `Manage` on the cluster: 42501 for viewer and +/// operator, whichever endpoint key and case; admin reads. +#[test] +fn an_endpoint_read_option_is_admin_only() { + let tmp = data(); + let path = tmp.path().join("a.csv"); + let path = path.to_str().unwrap(); + for key in krishiv_io::ENDPOINT_KEYS { + let upper = key.to_ascii_uppercase(); + for k in [key, upper.as_str()] { + for role in [Role::Viewer, Role::Operator] { + let s = session(tmp.path(), role, &[]); + assert_eq!( + read_with(&s, path, &[(k, ENDPOINT)]), + Err(DENIED.to_owned()), + "{role:?} {k}" + ); + } + let admin = session(tmp.path(), Role::Admin, &[]); + assert_eq!(read_with(&admin, path, &[(k, ENDPOINT)]), Ok(2), "{k}"); + } + } + // Other store options are not endpoints. + let operator = session(tmp.path(), Role::Operator, &[]); + assert_eq!( + read_with(&operator, path, &[("fs.s3a.region", "eu-west-1")]), + Ok(2) + ); + assert_eq!(read_with(&operator, path, &[]), Ok(2)); +} + +/// A store endpoint the session sets (SQL `SET`, `spark.conf.set`) is `Manage` on the cluster for +/// every query after it until it is reset; one the server configured for the session is not. +#[test] +fn an_endpoint_set_in_the_session_is_admin_only() { + let tmp = data(); + let read = format!("SELECT * FROM csv.`{}`", tmp.path().join("a.csv").display()); + + let operator = session(tmp.path(), Role::Operator, &[]); + assert_eq!(rows(&operator, &read), Ok(2)); + // SQL `SET` is refused before the value is stored. + assert_eq!( + rows(&operator, &format!("SET fs.s3a.endpoint = {ENDPOINT}")), + Err(DENIED.to_owned()) + ); + assert_eq!(operator.conf().get("fs.s3a.endpoint"), None); + assert_eq!(rows(&operator, &read), Ok(2)); + // `spark.conf.set` (Spark Connect, Python) stores it; every query after it is refused. + operator.conf().set("fs.s3a.endpoint", ENDPOINT).unwrap(); + assert_eq!(rows(&operator, &read), Err(DENIED.to_owned())); + assert_eq!(rows(&operator, "SELECT 1"), Err(DENIED.to_owned())); + operator.conf().unset("fs.s3a.endpoint").unwrap(); + assert_eq!(rows(&operator, &read), Ok(2)); + operator.conf().set("FS.HF.ENDPOINT", ENDPOINT).unwrap(); + assert_eq!(rows(&operator, &read), Err(DENIED.to_owned())); + rows(&operator, "RESET").unwrap(); + assert_eq!(rows(&operator, &read), Ok(2)); + + let admin = session(tmp.path(), Role::Admin, &[]); + assert_eq!( + rows(&admin, &format!("SET fs.s3a.endpoint = {ENDPOINT}")), + Ok(1) + ); + assert_eq!(rows(&admin, &read), Ok(2)); + + // The server's own endpoint (the session's builder configuration) needs nothing; changing it + // does. + let configured = session(tmp.path(), Role::Operator, &[("fs.s3a.endpoint", ENDPOINT)]); + assert_eq!(rows(&configured, &read), Ok(2)); + configured + .conf() + .set("fs.s3a.endpoint", "http://10.0.0.2:9000") + .unwrap(); + assert_eq!(rows(&configured, &read), Err(DENIED.to_owned())); + configured.conf().set("fs.s3a.endpoint", ENDPOINT).unwrap(); + assert_eq!(rows(&configured, &read), Ok(2)); +} + +/// Writes every path but holds no `Manage`. +struct WriterWithoutManage; + +impl AccessPolicy for WriterWithoutManage { + fn version(&self) -> PolicyVersion { + PolicyVersion(1) + } + + fn check<'a>( + &'a self, + _: &'a Principal, + _: &'a Securable, + privilege: Privilege, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + Ok(if privilege == Privilege::Manage { + AccessDecision::Deny { + reason: "no Manage".to_owned(), + } + } else { + AccessDecision::Allow + }) + }) + } +} + +/// A store endpoint in the write options is `Manage` on the cluster too, asked before anything +/// is written. +#[test] +fn an_endpoint_write_option_needs_manage() { + let tmp = tempfile::tempdir().unwrap(); + let s = Session::builder() + .principal(Principal::new("w", PrincipalKind::User)) + .access_policy(Arc::new(WriterWithoutManage)) + .build() + .unwrap(); + let out = tmp.path().join("out"); + let write = |options: &[(&str, &str)]| { + let mut w = block_on(s.sql("SELECT 1 AS a")) + .unwrap() + .write() + .mode("overwrite"); + for (k, v) in options { + w = w.option(k, v); + } + outcome(block_on(w.csv(out.to_str().unwrap())).map(|()| 0)) + }; + assert_eq!( + write(&[("fs.azure.endpoint", ENDPOINT)]), + Err(DENIED.to_owned()) + ); + assert!(!out.exists(), "nothing written before the check"); + assert_eq!(write(&[]), Ok(0)); +} + +/// `http(s)://` locations are refused for every role until an admin lists one in +/// `allowedPrefixes` (the one opt-in); then the role decides. +#[test] +fn http_locations_are_off_until_listed() { + let tmp = data(); + for role in ROLES { + let s = session(tmp.path(), role, &[]); + for path in [ + "https://example.com/data/a.csv", + "http://127.0.0.1:8080/a.csv", + ] { + assert_eq!( + rows(&s, &format!("SELECT * FROM csv.`{path}`")), + Err(DENIED.to_owned()), + "{role:?} {path}" + ); + } + } + let listed = config(&[( + "krishiv.io.objectStore.allowedPrefixes", + "https://example.com/data", + )]); + for role in ROLES { + let p = policy(&listed, role); + assert_eq!( + allows_read(&p, "https://example.com/data/a.csv"), + role != Role::Viewer, + "{role:?}" + ); + assert!( + !allows_read(&p, "https://example.com/other/a.csv"), + "{role:?}" + ); + assert!( + !allows_read(&p, "https://example.org/data/a.csv"), + "{role:?}" + ); + } +} + +/// Embedded use is unchanged: no confinement, endpoints per read and in the session work. +#[test] +fn embedded_sessions_are_unchanged() { + let tmp = data(); + let path = tmp.path().join("a.csv"); + let path = path.to_str().unwrap(); + let s = Session::builder() + .access_policy(Arc::new(AllowAll)) + .build() + .unwrap(); + assert_eq!(read_with(&s, path, &[("fs.s3a.endpoint", ENDPOINT)]), Ok(2)); + s.conf().set("fs.s3a.endpoint", ENDPOINT).unwrap(); + assert_eq!(rows(&s, &format!("SELECT * FROM csv.`{path}`")), Ok(2)); + let s = Session::new().unwrap(); + s.conf().set("fs.gs.endpoint", ENDPOINT).unwrap(); + assert_eq!(rows(&s, "SELECT 1"), Ok(1)); +} diff --git a/crates/krishiv-store/src/lib.rs b/crates/krishiv-store/src/lib.rs index 3c37487ad..a2d7ce868 100644 --- a/crates/krishiv-store/src/lib.rs +++ b/crates/krishiv-store/src/lib.rs @@ -55,8 +55,9 @@ pub use refreshing::RefreshingStore; pub use scope::CacheScope; pub use settings::{SettingValue, StoreSettings}; pub use stores::{ - AZURE_ACCOUNT_KEY, AZURE_ACCOUNT_NAME, AZURE_ENDPOINT, AZURE_SAS, GCS_ENDPOINT, GCS_KEY, - GCS_KEYFILE, GCS_STORAGE_ROOT_URL, HF_ENDPOINT, HF_TOKEN, ObjectScheme, OpenedStore, ParsedUrl, - S3_ACCESS_KEY, S3_ENDPOINT, S3_ENDPOINT_REGION, S3_PATH_STYLE_ACCESS, S3_REGION, S3_SECRET_KEY, - S3_SESSION_TOKEN, StoreRegistry, is_store_option, parse_object_url, + AZURE_ACCOUNT_KEY, AZURE_ACCOUNT_NAME, AZURE_ENDPOINT, AZURE_SAS, ENDPOINT_KEYS, GCS_ENDPOINT, + GCS_KEY, GCS_KEYFILE, GCS_STORAGE_ROOT_URL, HF_ENDPOINT, HF_TOKEN, ObjectScheme, OpenedStore, + ParsedUrl, S3_ACCESS_KEY, S3_ENDPOINT, S3_ENDPOINT_REGION, S3_PATH_STYLE_ACCESS, S3_REGION, + S3_SECRET_KEY, S3_SESSION_TOKEN, StoreRegistry, is_endpoint_key, is_store_option, + parse_object_url, }; diff --git a/crates/krishiv-store/src/stores.rs b/crates/krishiv-store/src/stores.rs index fb59bb4ce..b36ff8bfa 100644 --- a/crates/krishiv-store/src/stores.rs +++ b/crates/krishiv-store/src/stores.rs @@ -980,6 +980,24 @@ pub fn is_store_option(key: &str) -> bool { key.len() > 3 && key.as_bytes()[..3].eq_ignore_ascii_case(b"fs.") } +/// The keys that point a store at another host: S3, GCS, Azure and Hugging Face endpoints. In +/// server mode only a principal with `Manage` on the cluster may set one, per read or in its +/// session (P3-T067b), since the store would send the server's credentials there. +pub const ENDPOINT_KEYS: [&str; 5] = [ + S3_ENDPOINT, + GCS_STORAGE_ROOT_URL, + GCS_ENDPOINT, + AZURE_ENDPOINT, + HF_ENDPOINT, +]; + +/// Whether `key` is one of [`ENDPOINT_KEYS`], matched ASCII case-insensitively as the stores +/// look keys up. +#[must_use] +pub fn is_endpoint_key(key: &str) -> bool { + ENDPOINT_KEYS.iter().any(|k| k.eq_ignore_ascii_case(key)) +} + /// Finds `key` in the per-read `options`, ASCII case-insensitively. #[cfg(feature = "cloud")] fn find_option<'a>( From 4875d0529a34f4df5bb23f3a9bdd8a7a80ae8c8e Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:50:08 +0000 Subject: [PATCH 19/47] session, docs: public-api snapshot (OIDC, path confinement) and the config reference for the path keys (review M3, M4) Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-session/public-api.txt | 54 +++++++++++++++++++++++++++ docs/reference/config/index.md | 2 + 2 files changed, 56 insertions(+) diff --git a/crates/krishiv-session/public-api.txt b/crates/krishiv-session/public-api.txt index 87bfc9862..fba13e859 100644 --- a/crates/krishiv-session/public-api.txt +++ b/crates/krishiv-session/public-api.txt @@ -128,6 +128,31 @@ pub fn krishiv_session::agent::session_zone(core::option::Option<&str>) -> core: pub fn krishiv_session::agent::spark_float(f64) -> alloc::string::String pub fn krishiv_session::agent::spark_float32(f32) -> alloc::string::String pub mod krishiv_session::auth +#[non_exhaustive] pub enum krishiv_session::auth::Alg +pub krishiv_session::auth::Alg::Es256 +pub krishiv_session::auth::Alg::Es384 +pub krishiv_session::auth::Alg::Rs256 +pub krishiv_session::auth::Alg::Rs384 +pub krishiv_session::auth::Alg::Rs512 +impl krishiv_session::auth::Alg +pub const krishiv_session::auth::Alg::ALL: [Self; 5] +pub fn krishiv_session::auth::Alg::as_str(self) -> &'static str +pub fn krishiv_session::auth::Alg::parse(&str) -> core::option::Option +impl core::clone::Clone for krishiv_session::auth::Alg +pub fn krishiv_session::auth::Alg::clone(&self) -> krishiv_session::auth::Alg +impl core::cmp::Eq for krishiv_session::auth::Alg +impl core::cmp::Ord for krishiv_session::auth::Alg +pub fn krishiv_session::auth::Alg::cmp(&self, &krishiv_session::auth::Alg) -> core::cmp::Ordering +impl core::cmp::PartialEq for krishiv_session::auth::Alg +pub fn krishiv_session::auth::Alg::eq(&self, &krishiv_session::auth::Alg) -> bool +impl core::cmp::PartialOrd for krishiv_session::auth::Alg +pub fn krishiv_session::auth::Alg::partial_cmp(&self, &krishiv_session::auth::Alg) -> core::option::Option +impl core::fmt::Debug for krishiv_session::auth::Alg +pub fn krishiv_session::auth::Alg::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::hash::Hash for krishiv_session::auth::Alg +pub fn krishiv_session::auth::Alg::hash<__H: core::hash::Hasher>(&self, &mut __H) +impl core::marker::Copy for krishiv_session::auth::Alg +impl core::marker::StructuralPartialEq for krishiv_session::auth::Alg #[non_exhaustive] pub enum krishiv_session::auth::AuthFailure pub krishiv_session::auth::AuthFailure::Expired pub krishiv_session::auth::AuthFailure::Invalid @@ -284,11 +309,13 @@ impl core::marker::Copy for krishiv_session::auth::Surface impl core::marker::StructuralPartialEq for krishiv_session::auth::Surface #[non_exhaustive] pub struct krishiv_session::auth::AuthConfig pub krishiv_session::auth::AuthConfig::mtls: krishiv_session::auth::MtlsConfig +pub krishiv_session::auth::AuthConfig::oidc: krishiv_session::auth::OidcConfig pub krishiv_session::auth::AuthConfig::roles: krishiv_session::auth::RoleLists pub krishiv_session::auth::AuthConfig::throttle: krishiv_session::auth::ThrottleConfig pub krishiv_session::auth::AuthConfig::tokens: alloc::vec::Vec impl krishiv_session::auth::AuthConfig pub fn krishiv_session::auth::AuthConfig::from_settings<'a>(impl core::iter::traits::collect::IntoIterator) -> core::result::Result +pub fn krishiv_session::auth::AuthConfig::has_bearer_method(&self) -> bool pub fn krishiv_session::auth::AuthConfig::is_configured(&self) -> bool impl core::clone::Clone for krishiv_session::auth::AuthConfig pub fn krishiv_session::auth::AuthConfig::clone(&self) -> krishiv_session::auth::AuthConfig @@ -384,6 +411,31 @@ pub fn krishiv_session::auth::MtlsConfig::default() -> krishiv_session::auth::Mt impl core::fmt::Debug for krishiv_session::auth::MtlsConfig pub fn krishiv_session::auth::MtlsConfig::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for krishiv_session::auth::MtlsConfig +#[non_exhaustive] pub struct krishiv_session::auth::OidcConfig +pub krishiv_session::auth::OidcConfig::algorithms: alloc::vec::Vec +pub krishiv_session::auth::OidcConfig::allow_insecure_issuer: bool +pub krishiv_session::auth::OidcConfig::audience: core::option::Option +pub krishiv_session::auth::OidcConfig::clock_skew: core::time::Duration +pub krishiv_session::auth::OidcConfig::issuer: core::option::Option +pub krishiv_session::auth::OidcConfig::jwks_refresh: core::time::Duration +pub krishiv_session::auth::OidcConfig::jwks_uri: core::option::Option +pub krishiv_session::auth::OidcConfig::principal_claim: alloc::string::String +pub krishiv_session::auth::OidcConfig::role_claim: alloc::string::String +pub krishiv_session::auth::OidcConfig::roles: krishiv_session::auth::RoleLists +impl krishiv_session::auth::OidcConfig +pub fn krishiv_session::auth::OidcConfig::discovery_uri(&self) -> core::option::Option +pub fn krishiv_session::auth::OidcConfig::is_on(&self) -> bool +pub fn krishiv_session::auth::OidcConfig::uri_allowed(&self, &str) -> bool +impl core::clone::Clone for krishiv_session::auth::OidcConfig +pub fn krishiv_session::auth::OidcConfig::clone(&self) -> krishiv_session::auth::OidcConfig +impl core::cmp::Eq for krishiv_session::auth::OidcConfig +impl core::cmp::PartialEq for krishiv_session::auth::OidcConfig +pub fn krishiv_session::auth::OidcConfig::eq(&self, &krishiv_session::auth::OidcConfig) -> bool +impl core::default::Default for krishiv_session::auth::OidcConfig +pub fn krishiv_session::auth::OidcConfig::default() -> Self +impl core::fmt::Debug for krishiv_session::auth::OidcConfig +pub fn krishiv_session::auth::OidcConfig::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for krishiv_session::auth::OidcConfig pub struct krishiv_session::auth::RoleLists impl krishiv_session::auth::RoleLists pub fn krishiv_session::auth::RoleLists::role_of(&self, &str) -> core::option::Option @@ -857,7 +909,9 @@ pub fn krishiv_session::OptimizerStep::fmt(&self, &mut core::fmt::Formatter<'_>) impl core::marker::StructuralPartialEq for krishiv_session::OptimizerStep pub struct krishiv_session::PathPrefixPolicy impl krishiv_session::PathPrefixPolicy +pub fn krishiv_session::PathPrefixPolicy::from_config(&krishiv_common::config::Config, alloc::sync::Arc) -> core::result::Result pub fn krishiv_session::PathPrefixPolicy::new>(impl core::iter::traits::collect::IntoIterator, alloc::sync::Arc) -> core::result::Result +pub fn krishiv_session::PathPrefixPolicy::with_object_prefixes>(self, impl core::iter::traits::collect::IntoIterator) -> core::result::Result impl core::clone::Clone for krishiv_session::PathPrefixPolicy pub fn krishiv_session::PathPrefixPolicy::clone(&self) -> krishiv_session::PathPrefixPolicy impl core::fmt::Debug for krishiv_session::PathPrefixPolicy diff --git a/docs/reference/config/index.md b/docs/reference/config/index.md index d50a151fa..d23a0ce41 100644 --- a/docs/reference/config/index.md +++ b/docs/reference/config/index.md @@ -40,11 +40,13 @@ Keys under `krishiv.experimental.` are experimental and off by default. Spark al | `krishiv.io.credentials.refreshSkew` | duration | `5min` | cluster | startup | 0.1.0 | | How long before its expiry a short-lived object-store credential (vended by a catalog or a token service) is refreshed. A request that arrives inside this window fetches a new credential first; an expired credential is never used. `0s` refreshes only at expiry. | | `krishiv.io.footerReadBytes` | size | `64KB` | cluster | startup | 0.1.0 | | Size of the speculative read at the end of a file that fetches its footer in one request; a format may ask for more (up to 1 MiB). | | `krishiv.io.lateMaterialization` | enum (`auto`, `on`, `off`) | `auto` | session | user | 0.1.0 | | Whether a Parquet scan evaluates its pushed filter on the filter's columns before decoding the others for the rows that pass: `auto` decides per row group from the fraction of rows the filter kept before, `on` and `off` force it. Returned rows are the same either way; with the filter, decode errors confined to rows it rejects (a corrupt page or invalid UTF-8 in a column it does not read) are not raised. | +| `krishiv.io.local.allowedRoots` | string | (none) | cluster | startup | 0.2.0 | | Server mode (authentication configured): a comma-separated list of absolute paths of existing local directories (a path holding a comma cannot be listed) that reads and writes by path may use, for every role, admin included (ADR 0046 §3a). A path is compared after `.`, `..` and symbolic links are resolved, and a glob or directory read below a root fails at a link out of every root. Unset or empty: no local path is allowed in server mode. Embedded use is not confined by path; set there, the key only makes listings refuse links out of the roots. | | `krishiv.io.maxConcurrentRequests` | int | `64` | cluster | startup | 0.1.0 | | Most object-store requests in flight at once per store, over all queries of the process. The local file store also runs each request on a blocking thread of the I/O runtime, so at most `krishiv.io.blockingThreads` of them run at once. | | `krishiv.io.maxRequestBytes` | size | `16MB` | cluster | startup | 0.1.0 | | Ranges are not coalesced into requests larger than this (a single larger range is still fetched whole). | | `krishiv.io.maxRetries` | int | `5` | cluster | startup | 0.1.0 | | Retries of an object-store request that failed transiently (throttling, unavailable, timeout), with jittered exponential backoff honouring `Retry-After`. | | `krishiv.io.metadataCacheBytes` | size | `64MB` | cluster | startup | 0.1.0 | | Size of the in-memory cache of decoded file footers and metadata (least recently used entries are evicted). `0` disables it. | | `krishiv.io.multipartPartBytes` | size | `8MB` | cluster | startup | 0.1.0 | | Part size of multipart uploads (at least 5 MiB; doubled every 1,000 parts so a file never needs more than the 10,000 parts object stores allow). | +| `krishiv.io.objectStore.allowedPrefixes` | string | (none) | cluster | startup | 0.2.0 | | Server mode (authentication configured) only: a comma-separated list of object-store prefixes (`s3://bucket/prefix`, `gs://bucket`, `abfss://container@account.dfs.core.windows.net/prefix`; an Azure prefix names its account host) that reads and writes by path may use, for every role, admin included (ADR 0046 §3a). A prefix matches whole key segments (`s3://b/data` does not match `s3://b/data2`) and holds no glob character; `s3` and `s3a` name the same store; a path holding `%`, `?` or `#` is never allowed, so `?` globs are refused. It is the one opt-in for the server's own (ambient) credentials and for `http(s)://` locations, which are refused unless listed; a store endpoint option (`fs.s3a.endpoint` and the like, per read or set in a session) needs admin (P3-T067b). Unset or empty: no object-store path is allowed. Embedded use and a server without authentication do not read it. | | `krishiv.io.parquet.bloomFilter.enabled` | bool | `true` | session | user | 0.1.0 | | Whether a Parquet scan reads the stored Bloom filters of the row groups its statistics could not decide and skips a row group when a pushed equality or IN literal cannot be in it. Returned rows are the same either way: the filter only prunes, and a filter that fails a check is ignored. Files without Bloom filters pay nothing. | | `krishiv.io.parquet.dictionaryFilter.enabled` | bool | `true` | session | user | 0.2.0 | | Whether a Parquet scan reads the dictionary page of a column chunk whose data pages are all dictionary encoded, for the row groups its statistics could not decide, and skips a row group when the dictionary holds no literal of a pushed equality or IN. Returned rows are the same either way: the dictionary only prunes, and a page that fails a check is ignored. Dictionary pages above 16 MiB are not read. | | `krishiv.io.parquet.pageIndex.enabled` | bool | `true` | session | user | 0.1.0 | | Whether a Parquet scan reads the page index of the row groups its statistics could not decide and skips the pages the pushed filter rules out. Returned rows are the same either way: the index only prunes, and an index that fails a check is ignored. Files without a page index pay nothing. | From b4c150c3aa1920a63a74d65cc2d6c7c30ca64c9f Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:42:24 +0000 Subject: [PATCH 20/47] store, io, plan, session: T067b re-review fixes: every fs.* key but an inline credential is admin-only, table read options checked, S3 regions validated Opus review ops/reviews/2026-11-13-opus-A-t067b-egress-diff.md: - B1: krishiv_store::is_server_scoped_option replaces the five endpoint keys: every fs.* key except inline credentials (keys, secrets, tokens, SAS, the inline GCS key, the HF token) needs Manage on the cluster in server mode. That covers regions, accounts, proxies and key files (M1). S3 regions are validated before object_store formats them into the S3/STS host names. - B2: a table read's options (FROM t WITH (...), Connect table reads) are checked in bind (Found::StoreOptions); CREATE TABLE OPTIONS and SET SERDEPROPERTIES are checked before they are stored (readers are not asked again for an admin's stored options). - M3 (part): the link refusal no longer names the link. Its 42501 class and audit record, M2 (descriptor walk), M4 (Azure paths end to end, pre-existing) and M5 (one config for policy and runtime) are recorded on the T067b plan row. Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-common/src/config/registry.rs | 5 +- crates/krishiv-io/src/io/local_listing.rs | 5 +- crates/krishiv-io/src/io/mod.rs | 8 +- crates/krishiv-io/src/io/stores.rs | 9 +- crates/krishiv-io/src/lib.rs | 8 +- crates/krishiv-plan/src/analyzer/bind.rs | 44 ++++++++-- crates/krishiv-session/src/command.rs | 6 +- crates/krishiv-session/src/conf.rs | 10 +-- crates/krishiv-session/src/session.rs | 5 +- crates/krishiv-session/src/session/ops.rs | 18 ++-- crates/krishiv-session/src/table_commands.rs | 12 ++- .../src/table_commands/create.rs | 9 ++ crates/krishiv-session/tests/server_egress.rs | 85 +++++++++++++++++-- crates/krishiv-store/src/lib.rs | 9 +- crates/krishiv-store/src/stores.rs | 58 ++++++++----- crates/krishiv-store/src/stores/tests.rs | 38 +++++++++ docs/reference/config/index.md | 2 +- 17 files changed, 257 insertions(+), 74 deletions(-) diff --git a/crates/krishiv-common/src/config/registry.rs b/crates/krishiv-common/src/config/registry.rs index 807cc25ce..57429cbc9 100644 --- a/crates/krishiv-common/src/config/registry.rs +++ b/crates/krishiv-common/src/config/registry.rs @@ -1115,8 +1115,9 @@ pub static IO_OBJECT_STORE_ALLOWED_PREFIXES: ConfigEntry = ConfigEntry { `s3://b/data2`) and holds no glob character; `s3` and `s3a` name the same store; a \ path holding `%`, `?` or `#` is never allowed, so `?` globs are refused. It is the one \ opt-in for the server's own (ambient) credentials and for `http(s)://` locations, which \ - are refused unless listed; a store endpoint option (`fs.s3a.endpoint` and the like, per \ - read or set in a session) needs admin (P3-T067b). Unset or empty: no object-store path \ + are refused unless listed. Any `fs.*` store option but an inline credential (endpoints, \ + regions, accounts, key files), per read, on a table or set in a session, needs admin \ + (P3-T067b). Unset or empty: no object-store path \ is allowed. Embedded use and a server without authentication do not read it.", scope: Scope::Cluster, settable_by: SettableBy::Startup, diff --git a/crates/krishiv-io/src/io/local_listing.rs b/crates/krishiv-io/src/io/local_listing.rs index 2bfbf21d7..b2ce18746 100644 --- a/crates/krishiv-io/src/io/local_listing.rs +++ b/crates/krishiv-io/src/io/local_listing.rs @@ -250,9 +250,11 @@ fn walk_once( && !std::fs::canonicalize(&path) .is_ok_and(|t| roots.iter().any(|r| t.starts_with(r))) { + // The link's name is not shown: which entries lead out of the roots is not the + // reader's to learn. return Err(io_err(std::io::Error::new( ErrorKind::PermissionDenied, - format!("a link out of the allowed roots at {}", path.display()), + "the listing met a link out of the allowed roots", ))); } if m.is_dir() { @@ -997,6 +999,7 @@ mod range_read_tests { e.to_string().contains("a link out of the allowed roots"), "{e}" ); + assert!(!e.to_string().contains("k=1"), "the link is not named: {e}"); std::fs::remove_file(root.join("k=1")).unwrap(); std::os::unix::fs::symlink(outside.join("k=1/s.csv"), root.join("inner/s.csv")).unwrap(); diff --git a/crates/krishiv-io/src/io/mod.rs b/crates/krishiv-io/src/io/mod.rs index bf52543a3..ed668b720 100644 --- a/crates/krishiv-io/src/io/mod.rs +++ b/crates/krishiv-io/src/io/mod.rs @@ -72,10 +72,10 @@ pub use self::fault::{FaultStats, FaultStore, ScheduledRequest, TransientError}; pub use self::latency::{LATENCY_BUCKETS, LatencyHistogram}; use self::pace::{Direction, Pacer}; pub use self::stores::{ - AZURE_ACCOUNT_KEY, AZURE_ACCOUNT_NAME, AZURE_ENDPOINT, AZURE_SAS, ENDPOINT_KEYS, GCS_ENDPOINT, - GCS_KEY, GCS_KEYFILE, GCS_STORAGE_ROOT_URL, ObjectScheme, OpenedStore, ParsedUrl, - S3_ACCESS_KEY, S3_ENDPOINT, S3_ENDPOINT_REGION, S3_PATH_STYLE_ACCESS, S3_REGION, S3_SECRET_KEY, - S3_SESSION_TOKEN, StoreRegistry, StoreSettings, is_endpoint_key, parse_object_url, + AZURE_ACCOUNT_KEY, AZURE_ACCOUNT_NAME, AZURE_ENDPOINT, AZURE_SAS, GCS_ENDPOINT, GCS_KEY, + GCS_KEYFILE, GCS_STORAGE_ROOT_URL, ObjectScheme, OpenedStore, ParsedUrl, S3_ACCESS_KEY, + S3_ENDPOINT, S3_ENDPOINT_REGION, S3_PATH_STYLE_ACCESS, S3_REGION, S3_SECRET_KEY, + S3_SESSION_TOKEN, StoreRegistry, StoreSettings, is_server_scoped_option, parse_object_url, query_registry, register, register_with_options, }; pub use self::upload::Upload; diff --git a/crates/krishiv-io/src/io/stores.rs b/crates/krishiv-io/src/io/stores.rs index aa92c5cae..968022390 100644 --- a/crates/krishiv-io/src/io/stores.rs +++ b/crates/krishiv-io/src/io/stores.rs @@ -14,11 +14,10 @@ use std::sync::Arc; use krishiv_common::secret::OptionValue; pub(crate) use krishiv_store::is_store_option; pub use krishiv_store::{ - AZURE_ACCOUNT_KEY, AZURE_ACCOUNT_NAME, AZURE_ENDPOINT, AZURE_SAS, ENDPOINT_KEYS, GCS_ENDPOINT, - GCS_KEY, GCS_KEYFILE, GCS_STORAGE_ROOT_URL, HF_ENDPOINT, HF_TOKEN, ObjectScheme, OpenedStore, - ParsedUrl, S3_ACCESS_KEY, S3_ENDPOINT, S3_ENDPOINT_REGION, S3_PATH_STYLE_ACCESS, S3_REGION, - S3_SECRET_KEY, S3_SESSION_TOKEN, StoreRegistry, StoreSettings, is_endpoint_key, - parse_object_url, + AZURE_ACCOUNT_KEY, AZURE_ACCOUNT_NAME, AZURE_ENDPOINT, AZURE_SAS, GCS_ENDPOINT, GCS_KEY, + GCS_KEYFILE, GCS_STORAGE_ROOT_URL, HF_ENDPOINT, HF_TOKEN, ObjectScheme, OpenedStore, ParsedUrl, + S3_ACCESS_KEY, S3_ENDPOINT, S3_ENDPOINT_REGION, S3_PATH_STYLE_ACCESS, S3_REGION, S3_SECRET_KEY, + S3_SESSION_TOKEN, StoreRegistry, StoreSettings, is_server_scoped_option, parse_object_url, }; use object_store::path::Path; diff --git a/crates/krishiv-io/src/lib.rs b/crates/krishiv-io/src/lib.rs index 45dff1954..d20d6fd6b 100644 --- a/crates/krishiv-io/src/lib.rs +++ b/crates/krishiv-io/src/lib.rs @@ -67,10 +67,10 @@ pub use io::beneath::{AnchorError, BeneathStore}; pub use io::local_listing::SettledLocalFileSystem; pub use io::{ BlockCacheStats, ByteRange, CacheKey, CacheScope, CacheStats, DistinctSample, DistinctSamples, - ENDPOINT_KEYS, FileRef, Held, IoConfig, IoContext, IoCounters, IoScheduler, LATENCY_BUCKETS, - LatencyHistogram, ObjectScheme, OpenedStore, ParsedUrl, PinnedStore, PutIfAbsent, SampleKey, - SecretResolver, StoreId, StorePolicy, StoreRegistry, StoreSettings, Tail, VersionToken, - is_endpoint_key, parse_object_url, query_registry, register, register_with_options, + FileRef, Held, IoConfig, IoContext, IoCounters, IoScheduler, LATENCY_BUCKETS, LatencyHistogram, + ObjectScheme, OpenedStore, ParsedUrl, PinnedStore, PutIfAbsent, SampleKey, SecretResolver, + StoreId, StorePolicy, StoreRegistry, StoreSettings, Tail, VersionToken, + is_server_scoped_option, parse_object_url, query_registry, register, register_with_options, }; pub use json::JsonFormat; pub use options::{ diff --git a/crates/krishiv-plan/src/analyzer/bind.rs b/crates/krishiv-plan/src/analyzer/bind.rs index 03f4dab8f..07e52dfa8 100644 --- a/crates/krishiv-plan/src/analyzer/bind.rs +++ b/crates/krishiv-plan/src/analyzer/bind.rs @@ -160,6 +160,30 @@ enum Found<'a> { FileScan(&'a ReadSource, &'a [BoundColumn], Origin), /// The scopes of the secret references in a table read's options (E12). Secrets(Vec, Origin), + /// A table read whose options (`FROM t WITH (...)`) hold a server-scoped store key. + StoreOptions(Origin), +} + +/// Whether read `options` hold a server-scoped store key (`krishiv_io::is_server_scoped_option`). +fn server_scoped(options: &BTreeMap) -> bool { + options + .keys() + .any(|k| krishiv_io::is_server_scoped_option(k)) +} + +/// `Manage` on the cluster, for a read whose options hold a server-scoped store key (an +/// endpoint, region, account or key file could point the store, and the server's credentials, +/// at another host; P3-T067b), asked before anything is listed. +async fn authorize_store_options( + ctx: &AnalysisContext, + b: &mut Bindings, + checked: &mut Vec<(Securable, usize)>, + origin: Origin, +) -> Result<(), AnalysisError> { + let cluster = Securable::System("cluster".to_owned()); + authorize_as(ctx, b, checked, cluster, Privilege::Manage, origin) + .await + .map(|_| ()) } /// One node of the walk: reports what it reads, checks its functions, and @@ -180,6 +204,9 @@ fn visit<'a>( if !scopes.is_empty() { found.push(Found::Secrets(scopes, p.origin)); } + if server_scoped(options) { + found.push(Found::StoreOptions(p.origin)); + } let shadowed = name.len() == 1 && env.iter().any(|c| same(c, &name[0], cs)); if !shadowed { found.push(Found::Table(name, p.origin)); @@ -195,6 +222,9 @@ fn visit<'a>( if !scopes.is_empty() { found.push(Found::Secrets(scopes, p.origin)); } + if server_scoped(options) { + found.push(Found::StoreOptions(p.origin)); + } found.push(Found::Scan(name, s.version, &s.output, p.origin)); } source @ ReadSource::Files { .. } => { @@ -768,6 +798,10 @@ async fn load( authorize_usage(ctx, b, &mut checked, &scopes, origin).await?; continue; } + Found::StoreOptions(origin) => { + authorize_store_options(ctx, b, &mut checked, origin).await?; + continue; + } Found::Table(name, origin) => (name, None, &[][..], origin, false), Found::Scan(name, version, columns, origin) => (name, version, columns, origin, true), }; @@ -1065,11 +1099,11 @@ async fn bind_files( // before anything is listed or inferred. let scopes = super::access::secret_scopes(options.values().chain(&qualified)); authorize_usage(ctx, b, checked, &scopes, origin).await?; - // An endpoint option points the store, and the credentials it holds, at another host: - // `Manage` on the cluster (admin in server mode, P3-T067b), before anything is listed. - if options.keys().any(|k| krishiv_io::is_endpoint_key(k)) { - let cluster = Securable::System("cluster".to_owned()); - authorize_as(ctx, b, checked, cluster, Privilege::Manage, origin).await?; + // A server-scoped store option (endpoint, region, account, key file) could point the store, + // and the server's credentials, at another host: `Manage` on the cluster (admin in server + // mode, P3-T067b), before anything is listed. + if server_scoped(options) { + authorize_store_options(ctx, b, checked, origin).await?; } let columns = files .infer(format, &qualified, options) diff --git a/crates/krishiv-session/src/command.rs b/crates/krishiv-session/src/command.rs index e9c325077..f3cb55b17 100644 --- a/crates/krishiv-session/src/command.rs +++ b/crates/krishiv-session/src/command.rs @@ -156,10 +156,10 @@ impl Session { } .into()); } - // A store endpoint is `Manage` on the cluster (P3-T067b), asked before the + // A server-scoped store key is `Manage` on the cluster (P3-T067b), asked before the // value is stored, so a refused `SET` leaves the configuration as it was. - if krishiv_io::is_endpoint_key(key) { - self.authorize_endpoints( + if krishiv_io::is_server_scoped_option(key) { + self.authorize_store_options( krishiv_sql::StatementClass::Session, &self.state.conf.snapshot(), std::iter::once(key.as_str()), diff --git a/crates/krishiv-session/src/conf.rs b/crates/krishiv-session/src/conf.rs index 0c43a1c4a..2ea52e44c 100644 --- a/crates/krishiv-session/src/conf.rs +++ b/crates/krishiv-session/src/conf.rs @@ -117,15 +117,15 @@ impl ConfStore { self.versioned_snapshot().0 } - /// Whether `config` gives a store endpoint key (`krishiv_io::is_endpoint_key`) another value - /// than the configuration the session was built with: the session itself pointed a store at - /// another host (P3-T067b). - pub(crate) fn endpoint_changed(&self, config: &Config) -> bool { + /// Whether `config` gives a server-scoped store key (`krishiv_io::is_server_scoped_option`: + /// endpoints, regions, accounts, key files) another value than the configuration the session + /// was built with, i.e. whether the session itself redirected a store (P3-T067b). + pub(crate) fn store_settings_changed(&self, config: &Config) -> bool { let endpoints = |c: &Config| { let mut v: Vec<(String, String)> = c .explicit_unregistered() .into_iter() - .filter(|(k, _)| krishiv_io::is_endpoint_key(k)) + .filter(|(k, _)| krishiv_io::is_server_scoped_option(k)) .map(|(k, v)| (k.to_ascii_lowercase(), v.to_owned())) .collect(); v.sort(); diff --git a/crates/krishiv-session/src/session.rs b/crates/krishiv-session/src/session.rs index bc05ca699..a77d5919a 100644 --- a/crates/krishiv-session/src/session.rs +++ b/crates/krishiv-session/src/session.rs @@ -1259,9 +1259,10 @@ impl Session { env: &QueryEnv, text: Option<&RedactedSql>, ) -> Result { - // A store endpoint this session set sends the server's credentials to another host: + // A store setting this session redirected (endpoint, region, account) could send the + // server's credentials to another host: // asked once per analysis, before any table or file is read (P3-T067b). - self.authorize_endpoints(StatementClass::Read, &env.config, std::iter::empty()) + self.authorize_store_options(StatementClass::Read, &env.config, std::iter::empty()) .await?; // Temporary views over queries inline as subqueries, so the // analyzer sees one plan however the view was made. diff --git a/crates/krishiv-session/src/session/ops.rs b/crates/krishiv-session/src/session/ops.rs index ca8334e8f..3078c5a2d 100644 --- a/crates/krishiv-session/src/session/ops.rs +++ b/crates/krishiv-session/src/session/ops.rs @@ -43,7 +43,7 @@ impl Session { ) .await; crate::access::audit(self, StatementClass::Write, r)?; - self.authorize_endpoints( + self.authorize_store_options( StatementClass::Write, &self.state.conf.snapshot(), options.keys().map(String::as_str), @@ -63,17 +63,21 @@ impl Session { Ok(()) } - /// `Manage` on the cluster when `options` or the session's configuration `config` point a - /// store at another host than the session was built with (an endpoint key, P3-T067b): the - /// store would take the server's credentials there. Admin only in server mode; embedded - /// use (`AllowAll`) is unchanged. - pub(crate) async fn authorize_endpoints<'a>( + /// `Manage` on the cluster when `options` hold a server-scoped store key + /// (`krishiv_io::is_server_scoped_option`: endpoints, regions, accounts, key files; not inline + /// credentials), or when the session's configuration `config` changed one from what the + /// session was built with (P3-T067b). A store built from them could send the server's own + /// credentials to another host. Admin only in server mode; embedded use (`AllowAll`) is + /// unchanged. + pub(crate) async fn authorize_store_options<'a>( &self, class: StatementClass, config: &krishiv_common::config::Config, mut options: impl Iterator, ) -> Result<(), KrishivError> { - if !options.any(krishiv_io::is_endpoint_key) && !self.state.conf.endpoint_changed(config) { + if !options.any(krishiv_io::is_server_scoped_option) + && !self.state.conf.store_settings_changed(config) + { return Ok(()); } let r = authorize( diff --git a/crates/krishiv-session/src/table_commands.rs b/crates/krishiv-session/src/table_commands.rs index 2184d7661..e1ca7c06c 100644 --- a/crates/krishiv-session/src/table_commands.rs +++ b/crates/krishiv-session/src/table_commands.rs @@ -1183,8 +1183,16 @@ impl Session { } } AlterTableAction::SetSerdeProperties(props) => { - // The storage properties are the table's options (what the reader and writer get). - changed.options.extend(options_of(props, original)); + // The storage properties are the table's options (what the reader and writer get): + // a server-scoped store key in them is `Manage` on the cluster (P3-T067b). + let props = options_of(props, original); + self.authorize_store_options( + krishiv_sql::StatementClass::Ddl, + &self.state.conf.snapshot(), + props.keys().map(String::as_str), + ) + .await?; + changed.options.extend(props); } AlterTableAction::RenameTo { name: to, .. } => { return self.rename_table(&table, &self.table_name(to, &env)?).await; diff --git a/crates/krishiv-session/src/table_commands/create.rs b/crates/krishiv-session/src/table_commands/create.rs index d5117993b..ba5280b9c 100644 --- a/crates/krishiv-session/src/table_commands/create.rs +++ b/crates/krishiv-session/src/table_commands/create.rs @@ -83,6 +83,15 @@ impl Session { .into()); } let mut options = options_of(refuse_secret_options(&t.options, original)?, original); + // Stored store options redirect every later read of the table: a server-scoped key + // (endpoint, region, account, key file) is `Manage` on the cluster, asked before the + // table is stored (P3-T067b); readers are not asked again. + self.authorize_store_options( + krishiv_sql::StatementClass::Ddl, + &self.state.conf.snapshot(), + options.keys().map(String::as_str), + ) + .await?; let properties = options_of(&t.tblproperties, original); // A `path` option is the table's location, as in Spark. let path_key = options diff --git a/crates/krishiv-session/tests/server_egress.rs b/crates/krishiv-session/tests/server_egress.rs index 619fc41d4..3b2e9e838 100644 --- a/crates/krishiv-session/tests/server_egress.rs +++ b/crates/krishiv-session/tests/server_egress.rs @@ -122,35 +122,102 @@ fn ambient_credentials_reach_only_allowed_prefixes() { } } -/// A store endpoint in the read options is `Manage` on the cluster: 42501 for viewer and -/// operator, whichever endpoint key and case; admin reads. +/// The store keys that redirect a store (endpoints, regions, accounts, key files, any `fs.*` key +/// but an inline credential). +const SERVER_SCOPED: [(&str, &str); 9] = [ + ("fs.s3a.endpoint", ENDPOINT), + ("fs.gs.storage.root.url", ENDPOINT), + ("fs.gs.endpoint", ENDPOINT), + ("fs.azure.endpoint", ENDPOINT), + ("fs.hf.endpoint", ENDPOINT), + ("fs.s3a.region", "eu-west-1"), + ("fs.s3a.endpoint.region", "eu-west-1"), + ("fs.gs.auth.service.account.json.keyfile", "/etc/hostname"), + ("fs.azure.account.name", "otheracct"), +]; + +/// A server-scoped store key in the read options is `Manage` on the cluster: 42501 for viewer +/// and operator, whatever the key's case; admin reads. Inline credentials only authenticate +/// and stay open to readers. #[test] -fn an_endpoint_read_option_is_admin_only() { +fn a_server_scoped_read_option_is_admin_only() { let tmp = data(); let path = tmp.path().join("a.csv"); let path = path.to_str().unwrap(); - for key in krishiv_io::ENDPOINT_KEYS { + for (key, value) in SERVER_SCOPED { let upper = key.to_ascii_uppercase(); for k in [key, upper.as_str()] { for role in [Role::Viewer, Role::Operator] { let s = session(tmp.path(), role, &[]); assert_eq!( - read_with(&s, path, &[(k, ENDPOINT)]), + read_with(&s, path, &[(k, value)]), Err(DENIED.to_owned()), "{role:?} {k}" ); } let admin = session(tmp.path(), Role::Admin, &[]); - assert_eq!(read_with(&admin, path, &[(k, ENDPOINT)]), Ok(2), "{k}"); + assert_eq!(read_with(&admin, path, &[(k, value)]), Ok(2), "{k}"); } } - // Other store options are not endpoints. let operator = session(tmp.path(), Role::Operator, &[]); + for (key, value) in [ + ("fs.s3a.access.key", "AKIAEXAMPLE"), + ("fs.s3a.secret.key", "not-a-real-secret"), + ("fs.azure.sas.exports.lakeacct", "sv=x"), + ] { + assert_eq!(read_with(&operator, path, &[(key, value)]), Ok(2), "{key}"); + } + assert_eq!(read_with(&operator, path, &[]), Ok(2)); +} + +/// A table read's options (`FROM t WITH (...)`; Connect's table reads lower to the same node) are +/// checked like a read by path; a server-scoped key in a table's stored options (CREATE TABLE `OPTIONS`, +/// `SET SERDEPROPERTIES`) is `Manage` when it is stored, not on every later read. +#[test] +fn table_options_are_checked_where_they_are_given() { + let tmp = data(); + let dir = tmp.path().to_str().unwrap(); + let s = Session::builder() + .principal(Principal::new("w", PrincipalKind::User)) + .access_policy(Arc::new(WriterWithoutManage)) + .config("spark.sql.warehouse.dir", dir) + .build() + .unwrap(); + let create = |name: &str, options: &str| { + format!("CREATE TABLE {name} (a INT) USING csv {options} LOCATION '{dir}'") + }; + assert_eq!( + rows( + &s, + &create("t1", &format!("OPTIONS ('fs.s3a.endpoint' = '{ENDPOINT}')")) + ), + Err(DENIED.to_owned()) + ); + assert_eq!(rows(&s, "SHOW TABLES LIKE 't1'"), Ok(0), "nothing stored"); + rows(&s, &create("t2", "")).unwrap(); + assert_eq!(rows(&s, "SELECT * FROM t2"), Ok(2)); assert_eq!( - read_with(&operator, path, &[("fs.s3a.region", "eu-west-1")]), + rows( + &s, + &format!("SELECT * FROM t2 WITH ('fs.gs.storage.root.url' = '{ENDPOINT}')") + ), + Err(DENIED.to_owned()) + ); + assert_eq!( + rows( + &s, + "ALTER TABLE t2 SET SERDEPROPERTIES ('fs.s3a.region' = 'eu-west-1')" + ), + Err(DENIED.to_owned()) + ); + assert_eq!(rows(&s, "SELECT * FROM t2"), Ok(2), "nothing changed"); + assert_eq!( + rows( + &s, + "SELECT * FROM t2 WITH ('fs.s3a.access.key' = 'AKIAEXAMPLE')" + ), Ok(2) ); - assert_eq!(read_with(&operator, path, &[]), Ok(2)); } /// A store endpoint the session sets (SQL `SET`, `spark.conf.set`) is `Manage` on the cluster for diff --git a/crates/krishiv-store/src/lib.rs b/crates/krishiv-store/src/lib.rs index a2d7ce868..74bb7f94f 100644 --- a/crates/krishiv-store/src/lib.rs +++ b/crates/krishiv-store/src/lib.rs @@ -55,9 +55,8 @@ pub use refreshing::RefreshingStore; pub use scope::CacheScope; pub use settings::{SettingValue, StoreSettings}; pub use stores::{ - AZURE_ACCOUNT_KEY, AZURE_ACCOUNT_NAME, AZURE_ENDPOINT, AZURE_SAS, ENDPOINT_KEYS, GCS_ENDPOINT, - GCS_KEY, GCS_KEYFILE, GCS_STORAGE_ROOT_URL, HF_ENDPOINT, HF_TOKEN, ObjectScheme, OpenedStore, - ParsedUrl, S3_ACCESS_KEY, S3_ENDPOINT, S3_ENDPOINT_REGION, S3_PATH_STYLE_ACCESS, S3_REGION, - S3_SECRET_KEY, S3_SESSION_TOKEN, StoreRegistry, is_endpoint_key, is_store_option, - parse_object_url, + AZURE_ACCOUNT_KEY, AZURE_ACCOUNT_NAME, AZURE_ENDPOINT, AZURE_SAS, GCS_ENDPOINT, GCS_KEY, + GCS_KEYFILE, GCS_STORAGE_ROOT_URL, HF_ENDPOINT, HF_TOKEN, ObjectScheme, OpenedStore, ParsedUrl, + S3_ACCESS_KEY, S3_ENDPOINT, S3_ENDPOINT_REGION, S3_PATH_STYLE_ACCESS, S3_REGION, S3_SECRET_KEY, + S3_SESSION_TOKEN, StoreRegistry, is_server_scoped_option, is_store_option, parse_object_url, }; diff --git a/crates/krishiv-store/src/stores.rs b/crates/krishiv-store/src/stores.rs index b36ff8bfa..20a5e6d79 100644 --- a/crates/krishiv-store/src/stores.rs +++ b/crates/krishiv-store/src/stores.rs @@ -980,22 +980,42 @@ pub fn is_store_option(key: &str) -> bool { key.len() > 3 && key.as_bytes()[..3].eq_ignore_ascii_case(b"fs.") } -/// The keys that point a store at another host: S3, GCS, Azure and Hugging Face endpoints. In -/// server mode only a principal with `Manage` on the cluster may set one, per read or in its -/// session (P3-T067b), since the store would send the server's credentials there. -pub const ENDPOINT_KEYS: [&str; 5] = [ - S3_ENDPOINT, - GCS_STORAGE_ROOT_URL, - GCS_ENDPOINT, - AZURE_ENDPOINT, - HF_ENDPOINT, -]; - -/// Whether `key` is one of [`ENDPOINT_KEYS`], matched ASCII case-insensitively as the stores -/// look keys up. +/// Whether `key` is a store option that only a principal with `Manage` on the cluster may set +/// in server mode, per read, on a table or in a session (P3-T067b). That is every `fs.*` key +/// except the inline credentials (access keys, secrets, session tokens, the inline GCS key, Azure +/// keys and SAS tokens, the Hugging Face token), which only authenticate. The others choose where +/// a store connects (endpoints, regions, accounts, proxies) or which local file it opens (key +/// files), and a store built from them could send the server's own credentials elsewhere. +/// Matched ASCII case-insensitively, as the stores look keys up. #[must_use] -pub fn is_endpoint_key(key: &str) -> bool { - ENDPOINT_KEYS.iter().any(|k| k.eq_ignore_ascii_case(key)) +pub fn is_server_scoped_option(key: &str) -> bool { + is_store_option(key) && !is_inline_credential_key(key) +} + +/// A credential given inline: it authenticates, it does not choose a host or a file. +fn is_inline_credential_key(key: &str) -> bool { + (is_credential_key(key) && !key.eq_ignore_ascii_case(GCS_KEYFILE)) + || key.eq_ignore_ascii_case(HF_TOKEN) +} + +/// Checks an S3 region before object_store formats it into the S3 and STS host names +/// (`sts.{region}.amazonaws.com`): lower-case letters, digits and `-`, at most 32 bytes, so no +/// region can name another host. +#[cfg(feature = "cloud")] +fn checked_region(key: &str, region: String) -> Result { + let valid = !region.is_empty() + && region.len() <= 32 + && region + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); + if valid { + return Ok(region); + } + Err(invalid_option( + key, + "an S3 region holds only lower-case letters, digits and `-` (at most 32)", + ) + .into()) } /// Finds `key` in the per-read `options`, ASCII case-insensitively. @@ -1294,10 +1314,10 @@ fn s3_store( builder = builder.with_token(token.expose_secret()); } } - if let Some(region) = - text(config, options, S3_ENDPOINT_REGION)?.or(text(config, options, S3_REGION)?) - { - builder = builder.with_region(region); + if let Some(region) = text(config, options, S3_ENDPOINT_REGION)? { + builder = builder.with_region(checked_region(S3_ENDPOINT_REGION, region)?); + } else if let Some(region) = text(config, options, S3_REGION)? { + builder = builder.with_region(checked_region(S3_REGION, region)?); } if let Some(path_style) = path_style { builder = builder.with_virtual_hosted_style_request(!path_style); diff --git a/crates/krishiv-store/src/stores/tests.rs b/crates/krishiv-store/src/stores/tests.rs index 2d9ef50c9..05285cb05 100644 --- a/crates/krishiv-store/src/stores/tests.rs +++ b/crates/krishiv-store/src/stores/tests.rs @@ -794,3 +794,41 @@ fn store_ids_carry_a_hash_of_every_fs_setting() { let registry = StoreRegistry::new(config(&[("fs.s3a.endpoint", "http://a:9000")])); assert_ne!(registry.id_of(&url, None, &options), a); } + +/// object_store formats an S3 region into the S3 and STS host names, so a region that could name +/// another host (`evil.example#`, `/`, `@`, upper case, too long) is refused, per read or in the +/// session, before a store is built (P3-T067b review B1). +#[cfg(feature = "cloud")] +#[test] +fn a_region_cannot_name_a_host() { + let url = parse_object_url("s3://bucket/k").unwrap(); + let long = "a".repeat(33); + for key in [S3_REGION, S3_ENDPOINT_REGION] { + for bad in [ + "evil.example#", + "localhost:9000/", + "a@b", + "EU-WEST-1", + "", + long.as_str(), + ] { + let error = StoreRegistry::new(config(&[])) + .store_with_options(&url, &read_options(&[(key, bad)])) + .err() + .unwrap_or_else(|| panic!("{key}={bad} was accepted")); + assert_eq!( + error.error_class(), + ErrorClass::KrishivInvalidConfigValue, + "{key}={bad}" + ); + // (An empty session value is an unset one: the default region.) + if !bad.is_empty() { + let session = StoreRegistry::new(config(&[(key, bad)])); + assert!(session.store(&url).is_err(), "{key}={bad} in the session"); + } + } + StoreRegistry::new(config(&[(key, "eu-west-1")])) + .store(&url) + .unwrap(); + } +} diff --git a/docs/reference/config/index.md b/docs/reference/config/index.md index d23a0ce41..9b3860495 100644 --- a/docs/reference/config/index.md +++ b/docs/reference/config/index.md @@ -46,7 +46,7 @@ Keys under `krishiv.experimental.` are experimental and off by default. Spark al | `krishiv.io.maxRetries` | int | `5` | cluster | startup | 0.1.0 | | Retries of an object-store request that failed transiently (throttling, unavailable, timeout), with jittered exponential backoff honouring `Retry-After`. | | `krishiv.io.metadataCacheBytes` | size | `64MB` | cluster | startup | 0.1.0 | | Size of the in-memory cache of decoded file footers and metadata (least recently used entries are evicted). `0` disables it. | | `krishiv.io.multipartPartBytes` | size | `8MB` | cluster | startup | 0.1.0 | | Part size of multipart uploads (at least 5 MiB; doubled every 1,000 parts so a file never needs more than the 10,000 parts object stores allow). | -| `krishiv.io.objectStore.allowedPrefixes` | string | (none) | cluster | startup | 0.2.0 | | Server mode (authentication configured) only: a comma-separated list of object-store prefixes (`s3://bucket/prefix`, `gs://bucket`, `abfss://container@account.dfs.core.windows.net/prefix`; an Azure prefix names its account host) that reads and writes by path may use, for every role, admin included (ADR 0046 §3a). A prefix matches whole key segments (`s3://b/data` does not match `s3://b/data2`) and holds no glob character; `s3` and `s3a` name the same store; a path holding `%`, `?` or `#` is never allowed, so `?` globs are refused. It is the one opt-in for the server's own (ambient) credentials and for `http(s)://` locations, which are refused unless listed; a store endpoint option (`fs.s3a.endpoint` and the like, per read or set in a session) needs admin (P3-T067b). Unset or empty: no object-store path is allowed. Embedded use and a server without authentication do not read it. | +| `krishiv.io.objectStore.allowedPrefixes` | string | (none) | cluster | startup | 0.2.0 | | Server mode (authentication configured) only: a comma-separated list of object-store prefixes (`s3://bucket/prefix`, `gs://bucket`, `abfss://container@account.dfs.core.windows.net/prefix`; an Azure prefix names its account host) that reads and writes by path may use, for every role, admin included (ADR 0046 §3a). A prefix matches whole key segments (`s3://b/data` does not match `s3://b/data2`) and holds no glob character; `s3` and `s3a` name the same store; a path holding `%`, `?` or `#` is never allowed, so `?` globs are refused. It is the one opt-in for the server's own (ambient) credentials and for `http(s)://` locations, which are refused unless listed. Any `fs.*` store option but an inline credential (endpoints, regions, accounts, key files), per read, on a table or set in a session, needs admin (P3-T067b). Unset or empty: no object-store path is allowed. Embedded use and a server without authentication do not read it. | | `krishiv.io.parquet.bloomFilter.enabled` | bool | `true` | session | user | 0.1.0 | | Whether a Parquet scan reads the stored Bloom filters of the row groups its statistics could not decide and skips a row group when a pushed equality or IN literal cannot be in it. Returned rows are the same either way: the filter only prunes, and a filter that fails a check is ignored. Files without Bloom filters pay nothing. | | `krishiv.io.parquet.dictionaryFilter.enabled` | bool | `true` | session | user | 0.2.0 | | Whether a Parquet scan reads the dictionary page of a column chunk whose data pages are all dictionary encoded, for the row groups its statistics could not decide, and skips a row group when the dictionary holds no literal of a pushed equality or IN. Returned rows are the same either way: the dictionary only prunes, and a page that fails a check is ignored. Dictionary pages above 16 MiB are not read. | | `krishiv.io.parquet.pageIndex.enabled` | bool | `true` | session | user | 0.1.0 | | Whether a Parquet scan reads the page index of the row groups its statistics could not decide and skips the pages the pushed filter rules out. Returned rows are the same either way: the index only prunes, and an index that fails a check is ignored. Files without a page index pay nothing. | From 43ab2bd80e089bb908ac7919d9c5c7fde7f174d1 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:20:21 +0000 Subject: [PATCH 21/47] store, session: T067b verification fixes: the inline GCS key JSON is server-scoped, an admin's stored table options serve INSERTs Verification of b4c150c3a (ops/reviews/2026-11-13-opus-A-t067b-egress-diff.md): - 1 (blocking): fs.gs.auth.service.account.json is server-scoped too; object_store follows the JSON's gcs_base_url, so a non-admin could make the server fetch any HTTPS host. - 2: INSERT into a table whose stored options hold a server-scoped key no longer asks for Manage: the writer marks the table's own options (DataFrameWriter::options_are_the_tables) and the write check skips an option equal to a stored one. A different value is still checked. - Nit: the config doc says tuning keys are admin-only too, and that stored options are not asked about again. Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-common/src/config/registry.rs | 8 ++-- crates/krishiv-session/src/dataframe.rs | 1 + .../krishiv-session/src/dataframe/writer.rs | 10 +++++ crates/krishiv-session/src/session/ops.rs | 11 ++++- crates/krishiv-session/src/session/spill.rs | 3 ++ crates/krishiv-session/src/table_commands.rs | 3 +- .../src/table_commands/writes.rs | 3 +- crates/krishiv-session/tests/server_egress.rs | 42 ++++++++++++++++++- crates/krishiv-store/src/stores.rs | 16 ++++--- docs/reference/config/index.md | 2 +- 10 files changed, 84 insertions(+), 15 deletions(-) diff --git a/crates/krishiv-common/src/config/registry.rs b/crates/krishiv-common/src/config/registry.rs index 57429cbc9..73944fc8c 100644 --- a/crates/krishiv-common/src/config/registry.rs +++ b/crates/krishiv-common/src/config/registry.rs @@ -1115,9 +1115,11 @@ pub static IO_OBJECT_STORE_ALLOWED_PREFIXES: ConfigEntry = ConfigEntry { `s3://b/data2`) and holds no glob character; `s3` and `s3a` name the same store; a \ path holding `%`, `?` or `#` is never allowed, so `?` globs are refused. It is the one \ opt-in for the server's own (ambient) credentials and for `http(s)://` locations, which \ - are refused unless listed. Any `fs.*` store option but an inline credential (endpoints, \ - regions, accounts, key files), per read, on a table or set in a session, needs admin \ - (P3-T067b). Unset or empty: no object-store path \ + are refused unless listed. Any `fs.*` store option but an inline access key, secret, \ + token or SAS (so endpoints, regions, accounts, key files, the GCS key JSON and tuning \ + keys alike), per read, on a table or set in a session, needs admin (P3-T067b); a \ + table's options stored by an admin are not asked about again when others read or \ + insert. Unset or empty: no object-store path \ is allowed. Embedded use and a server without authentication do not read it.", scope: Scope::Cluster, settable_by: SettableBy::Startup, diff --git a/crates/krishiv-session/src/dataframe.rs b/crates/krishiv-session/src/dataframe.rs index 5f6b2ba96..b522ff49a 100644 --- a/crates/krishiv-session/src/dataframe.rs +++ b/crates/krishiv-session/src/dataframe.rs @@ -1526,6 +1526,7 @@ impl DataFrame { format: "parquet".to_owned(), mode: "error".to_owned(), options: BTreeMap::new(), + table_options: BTreeMap::new(), partition_by: Vec::new(), bucket_by: None, sort_by: None, diff --git a/crates/krishiv-session/src/dataframe/writer.rs b/crates/krishiv-session/src/dataframe/writer.rs index 5bf6d75bf..c794be505 100644 --- a/crates/krishiv-session/src/dataframe/writer.rs +++ b/crates/krishiv-session/src/dataframe/writer.rs @@ -12,6 +12,8 @@ pub struct DataFrameWriter { pub(super) format: String, pub(super) mode: String, pub(super) options: BTreeMap, + /// The stored options of the table this writer writes (`table_options`). + pub(super) table_options: BTreeMap, pub(super) partition_by: Vec, /// `bucketBy`: the bucket count and columns. pub(super) bucket_by: Option<(i32, Vec)>, @@ -23,6 +25,13 @@ pub struct DataFrameWriter { } impl DataFrameWriter { + /// Marks every option set so far as the stored options of the table written: not asked + /// about again by the write's access check (they were when they were stored, P3-T067b). + pub(crate) fn options_are_the_tables(mut self) -> Self { + self.table_options = self.options.clone(); + self + } + /// What to do when the path exists (Spark's save modes, ASCII /// case-insensitive): `errorifexists` (also `error`, `default`; the /// default) fails with `PATH_ALREADY_EXISTS`, `ignore` writes nothing, @@ -159,6 +168,7 @@ impl DataFrameWriter { mode: self.mode, path: OptionValue::from_user("path", path), options: self.options, + table_options: self.table_options, partition_by: self.partition_by, }; let df = self.df; diff --git a/crates/krishiv-session/src/session/ops.rs b/crates/krishiv-session/src/session/ops.rs index 3078c5a2d..c567e0f0e 100644 --- a/crates/krishiv-session/src/session/ops.rs +++ b/crates/krishiv-session/src/session/ops.rs @@ -34,6 +34,7 @@ impl Session { &self, at: &krishiv_exec::files::LocatedPath, options: &BTreeMap, + table_options: &BTreeMap, ) -> Result<(), KrishivError> { // The encoder resolves secret options (`encryptionKey`) for this principal: USAGE first. let r = krishiv_plan::analyzer::authorize_secret_refs( @@ -46,7 +47,11 @@ impl Session { self.authorize_store_options( StatementClass::Write, &self.state.conf.snapshot(), - options.keys().map(String::as_str), + // A table's own stored option was authorized when it was stored. + options + .iter() + .filter(|(k, v)| table_options.get(*k) != Some(*v)) + .map(|(k, _)| k.as_str()), ) .await?; let securable = Securable::Path(OptionValue::from_user("path", at.display.clone())); @@ -160,6 +165,7 @@ impl Session { mode, path, options, + table_options, partition_by, } = target; let mode = SaveMode::parse(&mode)?; @@ -196,7 +202,8 @@ impl Session { let at = self.locate_write_path(text, &options, env)?; // The query's own context: its counters are the write's bytes. let io = env.io.clone(); - self.authorize_write_path(&at, &options).await?; + self.authorize_write_path(&at, &options, &table_options) + .await?; // A link swapped into the checked path since fails the write // before anything is replaced, and again before the commit deletes // what an overwrite replaces. diff --git a/crates/krishiv-session/src/session/spill.rs b/crates/krishiv-session/src/session/spill.rs index 8a4aa1832..b549b9371 100644 --- a/crates/krishiv-session/src/session/spill.rs +++ b/crates/krishiv-session/src/session/spill.rs @@ -57,6 +57,9 @@ pub(crate) struct WriteTarget { pub(crate) mode: String, pub(crate) path: OptionValue, pub(crate) options: BTreeMap, + /// The stored options of the table written (`INSERT`), already authorized when they were + /// stored: an option equal to one of them is not asked about again (P3-T067b). + pub(crate) table_options: BTreeMap, pub(crate) partition_by: Vec, } diff --git a/crates/krishiv-session/src/table_commands.rs b/crates/krishiv-session/src/table_commands.rs index e1ca7c06c..ab0695269 100644 --- a/crates/krishiv-session/src/table_commands.rs +++ b/crates/krishiv-session/src/table_commands.rs @@ -1361,7 +1361,8 @@ fn table_writer( for (k, v) in &table.options { writer = writer.option(k, v); } - Ok(writer) + // Stored when an admin (or a `Manage` holder) created or altered the table: not asked again. + Ok(writer.options_are_the_tables()) } /// `UNSUPPORTED_FEATURE.TABLE_OPERATION` for `operation` on the table `name`. diff --git a/crates/krishiv-session/src/table_commands/writes.rs b/crates/krishiv-session/src/table_commands/writes.rs index acfe73aac..245eeec01 100644 --- a/crates/krishiv-session/src/table_commands/writes.rs +++ b/crates/krishiv-session/src/table_commands/writes.rs @@ -502,7 +502,8 @@ impl Session { managed_location(&self.warehouse_dir()?, &name.namespace, &name.name); let at = self.locate_write_path(&location.display().to_string(), &spec.options, &env)?; - self.authorize_write_path(&at, &spec.options).await?; + self.authorize_write_path(&at, &spec.options, &BTreeMap::new()) + .await?; // A managed table's own directory goes with it; any other one in the way stays. let own = self .state diff --git a/crates/krishiv-session/tests/server_egress.rs b/crates/krishiv-session/tests/server_egress.rs index 3b2e9e838..7dab3da29 100644 --- a/crates/krishiv-session/tests/server_egress.rs +++ b/crates/krishiv-session/tests/server_egress.rs @@ -124,7 +124,7 @@ fn ambient_credentials_reach_only_allowed_prefixes() { /// The store keys that redirect a store (endpoints, regions, accounts, key files, any `fs.*` key /// but an inline credential). -const SERVER_SCOPED: [(&str, &str); 9] = [ +const SERVER_SCOPED: [(&str, &str); 10] = [ ("fs.s3a.endpoint", ENDPOINT), ("fs.gs.storage.root.url", ENDPOINT), ("fs.gs.endpoint", ENDPOINT), @@ -134,6 +134,11 @@ const SERVER_SCOPED: [(&str, &str); 9] = [ ("fs.s3a.endpoint.region", "eu-west-1"), ("fs.gs.auth.service.account.json.keyfile", "/etc/hostname"), ("fs.azure.account.name", "otheracct"), + // object_store follows the JSON's `gcs_base_url`. + ( + "fs.gs.auth.service.account.json", + r#"{"gcs_base_url": "https://10.0.0.1"}"#, + ), ]; /// A server-scoped store key in the read options is `Manage` on the cluster: 42501 for viewer @@ -379,3 +384,38 @@ fn embedded_sessions_are_unchanged() { s.conf().set("fs.gs.endpoint", ENDPOINT).unwrap(); assert_eq!(rows(&s, "SELECT 1"), Ok(1)); } + +/// A table an admin stored with a server-scoped option (a MinIO region, say) is read and +/// written by others without `Manage`: the option was checked when it was stored. Overriding it +/// with another value is checked again. +#[test] +fn an_admins_stored_options_serve_other_writers() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let admin = Session::builder() + .access_policy(Arc::new(AllowAll)) + .config("spark.sql.warehouse.dir", dir) + .build() + .unwrap(); + rows( + &admin, + "CREATE TABLE m (a INT) USING csv OPTIONS ('fs.s3a.region' = 'eu-west-1')", + ) + .unwrap(); + let writer = Session::builder() + .runtime(Arc::clone(admin.runtime())) + .principal(Principal::new("w", PrincipalKind::User)) + .access_policy(Arc::new(WriterWithoutManage)) + .config("spark.sql.warehouse.dir", dir) + .build() + .unwrap(); + assert_eq!(rows(&writer, "INSERT INTO m VALUES (1)"), Ok(0)); + assert_eq!(rows(&writer, "SELECT * FROM m"), Ok(1)); + assert_eq!( + rows( + &writer, + "SELECT * FROM m WITH ('fs.s3a.region' = 'us-east-2')" + ), + Err(DENIED.to_owned()) + ); +} diff --git a/crates/krishiv-store/src/stores.rs b/crates/krishiv-store/src/stores.rs index 20a5e6d79..9b872f6f8 100644 --- a/crates/krishiv-store/src/stores.rs +++ b/crates/krishiv-store/src/stores.rs @@ -982,19 +982,23 @@ pub fn is_store_option(key: &str) -> bool { /// Whether `key` is a store option that only a principal with `Manage` on the cluster may set /// in server mode, per read, on a table or in a session (P3-T067b). That is every `fs.*` key -/// except the inline credentials (access keys, secrets, session tokens, the inline GCS key, Azure -/// keys and SAS tokens, the Hugging Face token), which only authenticate. The others choose where -/// a store connects (endpoints, regions, accounts, proxies) or which local file it opens (key -/// files), and a store built from them could send the server's own credentials elsewhere. +/// except the inline credentials that only authenticate (access keys, secrets, session tokens, +/// Azure keys and SAS tokens, the Hugging Face token). The others choose where a store connects +/// (endpoints, regions, accounts, proxies; the inline GCS service-account JSON too, whose +/// `gcs_base_url` object_store follows) or which local file it opens (key files), and a store +/// built from them could send the server's own credentials, or its requests, elsewhere. /// Matched ASCII case-insensitively, as the stores look keys up. #[must_use] pub fn is_server_scoped_option(key: &str) -> bool { is_store_option(key) && !is_inline_credential_key(key) } -/// A credential given inline: it authenticates, it does not choose a host or a file. +/// A credential given inline that only authenticates: it chooses no host and no file. (A GCS +/// key file names a file; the inline GCS JSON can name a host.) fn is_inline_credential_key(key: &str) -> bool { - (is_credential_key(key) && !key.eq_ignore_ascii_case(GCS_KEYFILE)) + (is_credential_key(key) + && !key.eq_ignore_ascii_case(GCS_KEYFILE) + && !key.eq_ignore_ascii_case(GCS_KEY)) || key.eq_ignore_ascii_case(HF_TOKEN) } diff --git a/docs/reference/config/index.md b/docs/reference/config/index.md index 9b3860495..d5185d576 100644 --- a/docs/reference/config/index.md +++ b/docs/reference/config/index.md @@ -46,7 +46,7 @@ Keys under `krishiv.experimental.` are experimental and off by default. Spark al | `krishiv.io.maxRetries` | int | `5` | cluster | startup | 0.1.0 | | Retries of an object-store request that failed transiently (throttling, unavailable, timeout), with jittered exponential backoff honouring `Retry-After`. | | `krishiv.io.metadataCacheBytes` | size | `64MB` | cluster | startup | 0.1.0 | | Size of the in-memory cache of decoded file footers and metadata (least recently used entries are evicted). `0` disables it. | | `krishiv.io.multipartPartBytes` | size | `8MB` | cluster | startup | 0.1.0 | | Part size of multipart uploads (at least 5 MiB; doubled every 1,000 parts so a file never needs more than the 10,000 parts object stores allow). | -| `krishiv.io.objectStore.allowedPrefixes` | string | (none) | cluster | startup | 0.2.0 | | Server mode (authentication configured) only: a comma-separated list of object-store prefixes (`s3://bucket/prefix`, `gs://bucket`, `abfss://container@account.dfs.core.windows.net/prefix`; an Azure prefix names its account host) that reads and writes by path may use, for every role, admin included (ADR 0046 §3a). A prefix matches whole key segments (`s3://b/data` does not match `s3://b/data2`) and holds no glob character; `s3` and `s3a` name the same store; a path holding `%`, `?` or `#` is never allowed, so `?` globs are refused. It is the one opt-in for the server's own (ambient) credentials and for `http(s)://` locations, which are refused unless listed. Any `fs.*` store option but an inline credential (endpoints, regions, accounts, key files), per read, on a table or set in a session, needs admin (P3-T067b). Unset or empty: no object-store path is allowed. Embedded use and a server without authentication do not read it. | +| `krishiv.io.objectStore.allowedPrefixes` | string | (none) | cluster | startup | 0.2.0 | | Server mode (authentication configured) only: a comma-separated list of object-store prefixes (`s3://bucket/prefix`, `gs://bucket`, `abfss://container@account.dfs.core.windows.net/prefix`; an Azure prefix names its account host) that reads and writes by path may use, for every role, admin included (ADR 0046 §3a). A prefix matches whole key segments (`s3://b/data` does not match `s3://b/data2`) and holds no glob character; `s3` and `s3a` name the same store; a path holding `%`, `?` or `#` is never allowed, so `?` globs are refused. It is the one opt-in for the server's own (ambient) credentials and for `http(s)://` locations, which are refused unless listed. Any `fs.*` store option but an inline access key, secret, token or SAS (so endpoints, regions, accounts, key files, the GCS key JSON and tuning keys alike), per read, on a table or set in a session, needs admin (P3-T067b); a table's options stored by an admin are not asked about again when others read or insert. Unset or empty: no object-store path is allowed. Embedded use and a server without authentication do not read it. | | `krishiv.io.parquet.bloomFilter.enabled` | bool | `true` | session | user | 0.1.0 | | Whether a Parquet scan reads the stored Bloom filters of the row groups its statistics could not decide and skips a row group when a pushed equality or IN literal cannot be in it. Returned rows are the same either way: the filter only prunes, and a filter that fails a check is ignored. Files without Bloom filters pay nothing. | | `krishiv.io.parquet.dictionaryFilter.enabled` | bool | `true` | session | user | 0.2.0 | | Whether a Parquet scan reads the dictionary page of a column chunk whose data pages are all dictionary encoded, for the row groups its statistics could not decide, and skips a row group when the dictionary holds no literal of a pushed equality or IN. Returned rows are the same either way: the dictionary only prunes, and a page that fails a check is ignored. Dictionary pages above 16 MiB are not read. | | `krishiv.io.parquet.pageIndex.enabled` | bool | `true` | session | user | 0.1.0 | | Whether a Parquet scan reads the page index of the row groups its statistics could not decide and skips the pages the pushed filter rules out. Returned rows are the same either way: the index only prunes, and an index that fails a check is ignored. Files without a page index pay nothing. | From dffcb5e85d567e6f7c148f2ebcef71e2cba22d24 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:37:56 +0000 Subject: [PATCH 22/47] server, cli: admin listener auth: /metrics and the UI behind ServerAuth and the ADR 0046 roles (P3-T002 PR-14, HTTP side) A Guard seam in krishiv-server: every guarded admin route (/metrics, the UI's /api/internal/*) asks one Guard what a request may do. TokenGuard keeps the printed start-up token of a server without authentication; SessionGuard (feature session) runs the server's ServerAuth and the ADR 0046 matrix through RolePolicy: /metrics needs Monitor on System(metrics) (every role), UI reads Monitor on System(system) and a cancel Operate on System(queries) (operator). Failures are 401 with WWW-Authenticate: Bearer, a throttled peer 503, a denial 403 with one krishiv::audit record. serve_capped records the peer address for the throttle; a guard without one fails closed. krishiv server builds one ServerAuth from krishiv.auth.* (never passed into the engine configuration) and uses it when tokens are configured. The non-loopback refusal stays until the Connect listener is wired. Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- Cargo.lock | 1 + crates/krishiv-cli/CLAUDE.md | 2 +- crates/krishiv-cli/src/server.rs | 45 +++- crates/krishiv-cli/tests/server.rs | 61 ++++- crates/krishiv-server/CLAUDE.md | 11 +- crates/krishiv-server/Cargo.toml | 1 + crates/krishiv-server/src/admin.rs | 13 +- crates/krishiv-server/src/guard.rs | 248 +++++++++++++++++++++ crates/krishiv-server/src/internal.rs | 71 ++++-- crates/krishiv-server/src/lib.rs | 6 +- crates/krishiv-server/src/metrics.rs | 97 +++++--- crates/krishiv-server/src/session_guard.rs | 199 +++++++++++++++++ crates/krishiv-server/tests/auth.rs | 164 ++++++++++++++ 13 files changed, 845 insertions(+), 74 deletions(-) create mode 100644 crates/krishiv-server/src/guard.rs create mode 100644 crates/krishiv-server/src/session_guard.rs create mode 100644 crates/krishiv-server/tests/auth.rs diff --git a/Cargo.lock b/Cargo.lock index 4b2bd350f..f3c5114ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2608,6 +2608,7 @@ dependencies = [ "krishiv-session", "krishiv-ui-assets", "serde_json", + "tempfile", "tokio", "tracing", ] diff --git a/crates/krishiv-cli/CLAUDE.md b/crates/krishiv-cli/CLAUDE.md index 7fe1570fb..b7479dcf7 100644 --- a/crates/krishiv-cli/CLAUDE.md +++ b/crates/krishiv-cli/CLAUDE.md @@ -10,7 +10,7 @@ The `krishiv` binary: sql, server, coordinator, worker, mcp, ui, doctor, migrate Paths are relative to this crate. - `src/main.rs` subcommand dispatch and the allocator/memory hook set-up - `src/sql.rs` the `krishiv sql` subcommand: input (`-e`, `-f`, stdin), output (`-o`), `--conf`, `--table`, `--timing`, `--agent`, `--max-rows`, `--max-seconds` -- `src/server.rs` `krishiv server` (feature `server`; `ui` implies it; design P3-T001): host checks before binding (non-loopback refused until P3-T002, root refused without `--allow-root`, core dumps off unless `krishiv.server.allowCoreDumps`), one tokio runtime for the Spark Connect listener (`krishiv-connect-server`, `--connect-port`, default 15002) and the admin listener (`--port` 8080: `/healthz`, `/readyz`, `/metrics` behind the printed token, the UI with `ui`), SIGTERM/SIGINT drain then exit 0 after the engine runtime is dropped, a second signal exits 130; `tests/server.rs` +- `src/server.rs` `krishiv server` (feature `server`; `ui` implies it; design P3-T001): host checks before binding (non-loopback refused until P3-T002, root refused without `--allow-root`, core dumps off unless `krishiv.server.allowCoreDumps`), one tokio runtime for the Spark Connect listener (`krishiv-connect-server`, `--connect-port`, default 15002) and the admin listener (`--port` 8080: `/healthz`, `/readyz`, `/metrics` and the UI with `ui`; with `krishiv.auth.*` tokens configured they are guarded by `SessionGuard` over one `ServerAuth`, else by a random token printed once; `krishiv.auth.*` settings never reach the engine configuration), SIGTERM/SIGINT drain then exit 0 after the engine runtime is dropped, a second signal exits 130; `tests/server.rs` - `src/check_compat/` (P3-T029) `krishiv check-compat `: `mod.rs` args, file walk, exit codes (0 none, 1 findings, 2 read errors), JSON/text report; `scan.rs` SQL / Python / notebook-cell scanners; `alternatives.json` workarounds for not-yet functions (names only supported functions; a unit test checks), `functions.json` the function matrix (generated from the SQL reference pages by `tests/scripts/gen_compat_matrix.py`); corpus `tests/check_compat_corpus/` (`handwritten/` with a hand-checked `expected.json`, `supported_only/` must give no findings, `generated/` from `tests/scripts/gen_check_compat_corpus.py`); JSON report is `{schema_version, files_scanned, findings, errors}` - `src/repl.rs` the interactive loop (no `-e`/`-f` on a terminal): `.timer`, `.format`, `.complete`, `.history`, history persistence - `src/complete.rs` completion sources (keywords, function registry, session catalog, config registry) diff --git a/crates/krishiv-cli/src/server.rs b/crates/krishiv-cli/src/server.rs index 4e0afdb04..25eb386c0 100644 --- a/crates/krishiv-cli/src/server.rs +++ b/crates/krishiv-cli/src/server.rs @@ -2,8 +2,9 @@ //! `krishiv server` (design P3-T001): one process, one engine runtime, two //! listeners on one tokio runtime: Spark Connect (gRPC, port 15002) and the //! admin server (HTTP, port 8080: `/healthz`, `/readyz`, `/metrics` and, -//! with the `ui` feature and `krishiv.ui.enabled`, the web UI behind a token -//! printed once at start). +//! with the `ui` feature and `krishiv.ui.enabled`, the web UI). With +//! `krishiv.auth.tokens.*` configured the admin routes take those principals +//! and the ADR 0046 roles (P3-T002); without, a random token printed once. //! //! Before anything binds: a non-loopback address is refused (no //! authentication exists until P3-T002), uid 0 is refused without @@ -26,6 +27,9 @@ use krishiv_common::config::{ use krishiv_connect_server::{ ConnectServer, ConnectServerConfig, SessionLimits, SessionManager, host, }; +use krishiv_server::guard::{Guard, TokenGuard}; +use krishiv_server::session_guard::SessionGuard; +use krishiv_session::auth::{AuthConfig, ServerAuth, is_auth_key}; use krishiv_session::{EngineRuntime, Session}; use tokio::net::TcpListener; use tokio::sync::watch; @@ -66,7 +70,20 @@ pub(crate) fn run(args: &ServerArgs) -> i32 { fn serve(args: &ServerArgs) -> Result<(), String> { let mut config = Config::new(); let mut builder = EngineRuntime::builder(); + // Authentication settings (secret references included) are read once here and never become + // engine configuration: a session could otherwise list them with `SET`. + let auth = AuthConfig::from_settings( + args.conf + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .filter(|(k, _)| is_auth_key(k)), + ) + .and_then(|c| ServerAuth::new(&c)) + .map_err(|e| e.to_string())?; for (k, v) in &args.conf { + if is_auth_key(k) { + continue; + } let warning = config.set(k, v).map_err(|e| e.to_string())?; // A Spark Connect setting Krishiv does not read would be ignored // silently: refuse it, naming the replacement where there is one. @@ -122,6 +139,7 @@ fn serve(args: &ServerArgs) -> Result<(), String> { run_listeners( runtime, &config, + auth, SocketAddr::new(host, connect_port), SocketAddr::new(host, args.port), ) @@ -147,6 +165,7 @@ fn flag(config: &Config, entry: &krishiv_common::config::ConfigEntry, default: b async fn run_listeners( runtime: Arc, config: &Config, + auth: ServerAuth, connect_addr: SocketAddr, admin_addr: SocketAddr, ) -> Result<(), String> { @@ -167,9 +186,17 @@ async fn run_listeners( let connect_bound = connect_listener.local_addr().map_err(|e| e.to_string())?; let admin_bound = admin_listener.local_addr().map_err(|e| e.to_string())?; + // With authentication configured the admin routes take the same principals as Connect; + // without it, a random token printed once (loopback only). + let authenticated = auth.is_enabled(); let token = krishiv_server::internal::random_token().map_err(|e| e.to_string())?; + let guard: Arc = if authenticated { + Arc::new(SessionGuard::new(Arc::new(auth))) + } else { + Arc::new(TokenGuard::new(&token)) + }; let ready = Arc::new(AtomicBool::new(false)); - let app = admin_router(&runtime, config, &token, Arc::clone(&ready), admin_bound)?; + let app = admin_router(&runtime, config, guard, Arc::clone(&ready), admin_bound)?; let connect = ConnectServer::new( Arc::clone(&manager), ConnectServerConfig::from_config(config), @@ -193,7 +220,9 @@ async fn run_listeners( println!("krishiv server: Spark Connect on sc://{connect_bound}"); println!("krishiv server: admin on http://{admin_bound}"); let ui = flag(config, &UI_ENABLED, true) && cfg!(feature = "ui"); - if ui { + if authenticated { + println!("krishiv server: admin routes need a configured token (krishiv.auth.tokens)"); + } else if ui { println!("open http://{admin_bound}/?token={token}"); } else if metrics_need_token(config, admin_bound) { // `/metrics` is behind the admin token: print it once. @@ -222,22 +251,22 @@ async fn run_listeners( fn admin_router( runtime: &Arc, config: &Config, - token: &str, + guard: Arc, ready: Arc, bound: SocketAddr, ) -> Result { let metrics_on = flag(config, &METRICS_ENABLED, false); let public = metrics_on && !metrics_need_token(config, bound); let snapshot_runtime = Arc::clone(runtime); - let metrics = krishiv_server::metrics::metrics_route( + let metrics = krishiv_server::metrics::metrics_route_guarded( metrics_on, Arc::new(move || snapshot_runtime.metrics()), - (!public).then_some(token), + (!public).then(|| Arc::clone(&guard)), ); let base = if cfg!(feature = "ui") && flag(config, &UI_ENABLED, true) { let source = krishiv_server::session_source::SessionSource::new(Arc::clone(runtime)) .map_err(|e| e.to_string())?; - krishiv_server::internal::router_with_ui(Arc::new(source), token) + krishiv_server::internal::router_with_ui_guard(Arc::new(source), guard) } else { krishiv_server::api_router() }; diff --git a/crates/krishiv-cli/tests/server.rs b/crates/krishiv-cli/tests/server.rs index 163e0a295..a5b5fabe9 100644 --- a/crates/krishiv-cli/tests/server.rs +++ b/crates/krishiv-cli/tests/server.rs @@ -122,6 +122,11 @@ impl Drop for Running { } fn start(spill: &std::path::Path) -> Running { + start_with(spill, &[]) +} + +/// [`start`] with more `--conf KEY=VALUE` settings. +fn start_with(spill: &std::path::Path, conf: &[String]) -> Running { let mut child = krishiv() // `--allow-root`: CI containers may run as root. .args([ @@ -134,6 +139,7 @@ fn start(spill: &std::path::Path) -> Running { ]) .args(["--conf", "krishiv.metrics.enabled=true", "--conf"]) .arg(format!("krishiv.spill.dir={}", spill.display())) + .args(conf.iter().flat_map(|c| ["--conf", c.as_str()])) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() @@ -161,12 +167,18 @@ fn start(spill: &std::path::Path) -> Running { } fn http_get(addr: &str, path: &str) -> String { + http_get_as(addr, path, None) +} + +/// `GET path` with `Authorization: Bearer token` when `token` is given. +fn http_get_as(addr: &str, path: &str, token: Option<&str>) -> String { let mut s = TcpStream::connect(addr).expect("connect"); s.set_read_timeout(Some(Duration::from_secs(10))) .expect("timeout"); + let auth = token.map_or(String::new(), |t| format!("Authorization: Bearer {t}\r\n")); write!( s, - "GET {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" + "GET {path} HTTP/1.1\r\nHost: x\r\n{auth}Connection: close\r\n\r\n" ) .expect("write"); let mut out = String::new(); @@ -284,3 +296,50 @@ fn idle_client_survives_keepalive() { s.stop()\n", ); } + +#[test] +fn an_inline_auth_secret_is_refused_and_not_echoed() { + let out = krishiv() + .args(["server", "--port", "0", "--connect-port", "0", "--conf"]) + .arg("krishiv.auth.tokens.a.secret=inline-canary-secret-0123456789abcdef") + .output() + .expect("run krishiv server"); + assert_eq!(out.status.code(), Some(1)); + let all = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert!(!all.contains("inline-canary-secret"), "{all}"); + assert!(!all.contains("Spark Connect on"), "{all}"); +} + +#[cfg(unix)] +#[test] +fn configured_tokens_guard_the_admin_routes() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().expect("tempdir"); + let token = "prometheus-scrape-token-0123456789abcdef"; + let path = dir.path().join("prom"); + std::fs::write(&path, token).expect("write secret"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("chmod"); + let conf = [ + format!("krishiv.auth.tokens.prom.secret=file:{}", path.display()), + "krishiv.auth.tokens.prom.principal=prometheus".to_owned(), + "krishiv.auth.tokens.prom.role=viewer".to_owned(), + ]; + let server = start_with(dir.path(), &conf); + let none = http_get(&server.admin, "/metrics"); + assert!(none.starts_with("HTTP/1.1 401"), "{none}"); + let scrape = http_get_as(&server.admin, "/metrics", Some(token)); + assert!(scrape.starts_with("HTTP/1.1 200"), "{scrape}"); + // A viewer sees no other principal's work in the UI's data routes (403 with the UI on; the + // route does not exist without it). + let ui = http_get_as(&server.admin, "/api/internal/queries", Some(token)); + assert!( + ui.starts_with("HTTP/1.1 403") || ui.starts_with("HTTP/1.1 404"), + "{ui}" + ); + let probe = http_get(&server.admin, "/healthz"); + assert!(probe.starts_with("HTTP/1.1 200"), "{probe}"); +} diff --git a/crates/krishiv-server/CLAUDE.md b/crates/krishiv-server/CLAUDE.md index f017b595f..46a19f236 100644 --- a/crates/krishiv-server/CLAUDE.md +++ b/crates/krishiv-server/CLAUDE.md @@ -9,12 +9,15 @@ Admin and UI API server (REST, OpenAPI), serves the web UI. Today: `/api/v1/vers ## Key files Paths are relative to this crate. - `src/lib.rs` `router()`, `serve` and `run_blocking` -- `src/internal.rs` the UI backend (P2-T133 part a): `router_with_ui(source, token)`, the `UiSource` trait (system-table rows as JSON, cancel), `/api/internal/{health,queries,queries/{id},queries/{id}/cancel,history,compare,resources,events}`; every route needs the token (`Authorization: Bearer` for any method; the `krishiv_ui_token` cookie or `?token=` only for GET), query text is shown with its literals masked, at most 16 event streams share one snapshot a period, every response of that router carries the CSP / `frame-ancestors 'none'` headers, columns are allowlisted per endpoint, `events` is SSE at one event a second -- `src/admin.rs` `krishiv server`'s admin endpoints (design P3-T001 §3.5): `health_routes` (`/healthz` open, `/readyz` 200/503 from a ready flag, fixed bodies), `with_limits` (request timeout 30 s, SSE `/events` exempt; body limit 2 MiB), `serve_capped` (connection cap, graceful shutdown); `tests/admin.rs` +- `src/guard.rs` (P3-T002) `Guard`, the one check every guarded admin-listener route asks: `GuardRequest` (presented token, malformed flag, peer, `GuardSurface`, `Access`: `Authenticated`, `Metrics` = `Monitor` on `System("metrics")`, `Monitor` on `System("system")`, `Operate` on `System("queries")`), `Refusal` (401 / 503 / 403 / 500 fail closed), `TokenGuard` (the start-up token of a server without authentication), `PeerAddr` (the peer `serve_capped` records as `ConnectInfo` for the throttle) +- `src/session_guard.rs` (feature `session`) `SessionGuard`: the server's `ServerAuth`, then the ADR 0046 matrix through `RolePolicy` (no role table of its own); a denial writes one `krishiv::audit` record +- `src/internal.rs` the UI backend (P2-T133 part a): `router_with_ui(source, token)` and `router_with_ui_guard(source, guard)` (GET needs `Access::Monitor`, anything else `Access::Operate`: operator), the `UiSource` trait (system-table rows as JSON, cancel), `/api/internal/{health,queries,queries/{id},queries/{id}/cancel,history,compare,resources,events}`; every route needs the token (`Authorization: Bearer` for any method; the `krishiv_ui_token` cookie or `?token=` only for GET), query text is shown with its literals masked, at most 16 event streams share one snapshot a period, every response of that router carries the CSP / `frame-ancestors 'none'` headers, columns are allowlisted per endpoint, `events` is SSE at one event a second +- `src/admin.rs` `krishiv server`'s admin endpoints (design P3-T001 §3.5): `health_routes` (`/healthz` open, `/readyz` 200/503 from a ready flag, fixed bodies; never guarded), `with_limits` (request timeout 30 s, SSE `/events` exempt; body limit 2 MiB), `serve_capped` (connection cap, graceful shutdown); `tests/admin.rs` - `src/session_source.rs` `SessionSource` (UI over a runtime) and `EmbeddedUi` (serves it from a host process, token + stop on drop) -- `src/metrics.rs` `router_with_metrics`, `router_with_metrics_auth` (with a token, `/metrics` needs `Authorization: Bearer `, compared in constant time; a refusal is a JSON 401 and takes no snapshot; an empty token refuses everyone; a disabled `/metrics` stays a 404 for every caller) and `render` (feature `metrics-prometheus` only). `metrics_route` is the route alone, merged into `krishiv server`'s admin router (P3-T001: the printed admin token until T002) +- `src/metrics.rs` `router_with_metrics`, `router_with_metrics_auth` (with a token, `/metrics` needs `Authorization: Bearer `, compared in constant time; a refusal is a JSON 401 and takes no snapshot; an empty token refuses everyone; a disabled `/metrics` stays a 404 for every caller) and `render` (feature `metrics-prometheus` only). `metrics_route` is the route alone, `metrics_route_guarded` the same behind a `Guard` (`Access::Metrics`, every role), merged into `krishiv server`'s admin router - `examples/console_capture.rs` runs TPC-H queries through an embedded runtime and serves the UI over it; the website's console captures (`site/capture-console.sh` in the docs repo) use it - `tests/serve.rs` server tests +- `tests/auth.rs` (features `session` + `metrics-prometheus`) the admin listener with configured tokens: per-role access, 401 / throttle 503, open probes - `tests/metrics.rs` `/metrics` exposition tests (feature `metrics-prometheus` only) ## Extension points @@ -30,7 +33,7 @@ Paths are relative to this crate. `metrics-prometheus` feature and `krishiv.metrics.enabled` (else 404). ## ADRs (in `../krishiv-internal/adr/`) -0024, 0004, 0026 +0024, 0004, 0026, 0046 ## Tests `cargo nextest run -p krishiv-server --no-tests=pass` · `cargo clippy -p krishiv-server --all-targets -- -D warnings` diff --git a/crates/krishiv-server/Cargo.toml b/crates/krishiv-server/Cargo.toml index e139475e2..654021800 100644 --- a/crates/krishiv-server/Cargo.toml +++ b/crates/krishiv-server/Cargo.toml @@ -33,6 +33,7 @@ session = ["dep:krishiv-session", "dep:krishiv-common", "dep:arrow-array", "dep: [dev-dependencies] krishiv-session = { path = "../krishiv-session" } krishiv-exec = { path = "../krishiv-exec" } +tempfile.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "net", "io-util", "macros", "time"] } [lints] diff --git a/crates/krishiv-server/src/admin.rs b/crates/krishiv-server/src/admin.rs index ba2ce18d0..f6a557d03 100644 --- a/crates/krishiv-server/src/admin.rs +++ b/crates/krishiv-server/src/admin.rs @@ -116,13 +116,18 @@ pub async fn serve_capped( max_connections.clamp(1, Semaphore::MAX_PERMITS), )), }; - axum::serve(listener, app) - .with_graceful_shutdown(shutdown) - .await + // The peer address rides on every request for the authentication throttle. + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown) + .await } /// A listener whose connections each hold a permit until they close. -struct CappedListener { +#[derive(Debug)] +pub struct CappedListener { inner: TcpListener, permits: Arc, } diff --git a/crates/krishiv-server/src/guard.rs b/crates/krishiv-server/src/guard.rs new file mode 100644 index 000000000..f47560fc6 --- /dev/null +++ b/crates/krishiv-server/src/guard.rs @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Who may use an admin-listener route (P3-T002 design §3.1, ADR 0046 §3, §3c, §8). +//! +//! Every guarded route of the admin listener (`/metrics`, the UI's `/api/internal/*`) asks one +//! [`Guard`] whether a request may go on. A route states what it needs as an [`Access`]; the +//! surface extracts the presented token its own way (a header for `/metrics`, a header, cookie +//! or query parameter for the UI) and hands it over in a [`GuardRequest`]. +//! +//! - [`TokenGuard`]: one shared secret (the token `krishiv server` prints when no +//! authentication is configured, loopback only). The token is the whole check: every +//! [`Access`] is granted to its holder. +//! - `SessionGuard` (feature `session`): the server's `ServerAuth` and the ADR 0046 role +//! matrix. Failures are `401` (`503` while the peer is throttled), denials `403`. +//! +//! `/healthz` and `/readyz` are never guarded (ADR 0046 §3c). + +use std::fmt; +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::extract::connect_info::Connected; +use axum::serve::IncomingStream; +use futures::future::BoxFuture; + +/// What a route needs beyond an authenticated caller (ADR 0046 §3, §3c). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum Access { + /// Any authenticated principal, whatever its role (`/api/v1/version`). + Authenticated, + /// A `/metrics` scrape: `Monitor` on `System("metrics")`, held by every role. + Metrics, + /// Other principals' work shown in the UI: `Monitor` on `System("system")` (operator). + Monitor, + /// Cancelling another principal's query from the UI: `Operate` on `System("queries")` + /// (operator). + Operate, +} + +/// The surface a guarded request arrived on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum GuardSurface { + /// `GET /metrics`. + Metrics, + /// The web UI and its `/api/internal` routes. + Ui, + /// The admin HTTP API. + Admin, +} + +/// What a surface extracted from one request. Never logged: `Debug` hides the token. +#[derive(Clone, Copy)] +#[non_exhaustive] +pub struct GuardRequest<'a> { + /// The presented token, if the surface found one. + pub token: Option<&'a str>, + /// An `Authorization` header was present but held no usable `Bearer` token. + pub malformed: bool, + /// The peer's address, when the listener records it ([`PeerAddr`]). + pub peer: Option, + /// The surface. + pub surface: GuardSurface, + /// What the route needs. + pub access: Access, +} + +impl<'a> GuardRequest<'a> { + /// A request on `surface` for `access`, with no token and no peer. + #[must_use] + pub fn new(surface: GuardSurface, access: Access) -> Self { + Self { + token: None, + malformed: false, + peer: None, + surface, + access, + } + } + + /// The same request presenting `token`. + #[must_use] + pub fn with_token(mut self, token: Option<&'a str>) -> Self { + self.token = token; + self + } + + /// The same request marked as carrying an unusable `Authorization` header. + #[must_use] + pub fn with_malformed(mut self, malformed: bool) -> Self { + self.malformed = malformed; + self + } + + /// The same request from `peer`. + #[must_use] + pub fn with_peer(mut self, peer: Option) -> Self { + self.peer = peer; + self + } +} + +impl fmt::Debug for GuardRequest<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("GuardRequest") + .field("token", &self.token.map(|_| "")) + .field("malformed", &self.malformed) + .field("peer", &self.peer) + .field("surface", &self.surface) + .field("access", &self.access) + .finish() + } +} + +/// Why a request was refused. Carries no credential and no policy reason. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Refusal { + /// No valid credential (`401`, `WWW-Authenticate: Bearer`). + Unauthenticated, + /// The peer is throttled after repeated failures, or a provider is unreachable (`503`). + Unavailable, + /// Authenticated, but the role does not hold what the route needs (`403`). + Denied, + /// The guard cannot decide (no peer address recorded): refused, fail closed (`500`). + Misconfigured, +} + +/// Decides whether a request may use a guarded route. +pub trait Guard: Send + Sync + 'static { + /// `Ok` lets the request through. + /// + /// # Errors + /// The [`Refusal`]; the guard has already logged it. + fn check<'a>(&'a self, request: GuardRequest<'a>) -> BoxFuture<'a, Result<(), Refusal>>; +} + +/// One shared secret: the start-up token of a server without authentication. +#[derive(Clone)] +pub struct TokenGuard { + token: Arc, +} + +impl TokenGuard { + /// A guard accepting exactly `token`. An empty `token` accepts nothing. + #[must_use] + pub fn new(token: &str) -> Self { + Self { + token: Arc::from(token), + } + } +} + +impl fmt::Debug for TokenGuard { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TokenGuard").finish_non_exhaustive() + } +} + +impl Guard for TokenGuard { + fn check<'a>(&'a self, request: GuardRequest<'a>) -> BoxFuture<'a, Result<(), Refusal>> { + let ok = !self.token.is_empty() + && request + .token + .is_some_and(|t| same_secret(t.as_bytes(), self.token.as_bytes())); + Box::pin(std::future::ready(if ok { + Ok(()) + } else { + Err(Refusal::Unauthenticated) + })) + } +} + +/// Whether `a` and `b` are equal, in time that depends only on their lengths. +pub(crate) fn same_secret(a: &[u8], b: &[u8]) -> bool { + let mut diff = a.len() ^ b.len(); + for i in 0..a.len().max(b.len()) { + diff |= usize::from(a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(0)); + } + // `black_box` keeps the compiler from turning the fold into an early exit. The length is not + // secret from a timing observer; the token's bytes are not revealed by the time taken. + std::hint::black_box(diff) == 0 +} + +/// The peer address of an admin connection, recorded by [`crate::admin::serve_capped`] as +/// `ConnectInfo` for the failure throttle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PeerAddr(pub SocketAddr); + +impl Connected> for PeerAddr { + fn connect_info(stream: IncomingStream<'_, crate::admin::CappedListener>) -> Self { + Self(*stream.remote_addr()) + } +} + +impl Connected for PeerAddr { + fn connect_info(addr: SocketAddr) -> Self { + Self(addr) + } +} + +/// The peer recorded on `extensions`, if the listener records one. +#[must_use] +pub fn peer_of(extensions: &axum::http::Extensions) -> Option { + extensions + .get::>() + .map(|c| c.0.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn run(guard: &dyn Guard, token: Option<&str>) -> Result<(), Refusal> { + let request = GuardRequest::new(GuardSurface::Ui, Access::Operate).with_token(token); + guard.check(request).await + } + + #[tokio::test] + async fn token_guard_accepts_exactly_its_token() { + let g = TokenGuard::new("s3cret"); + assert_eq!(run(&g, Some("s3cret")).await, Ok(())); + assert_eq!(run(&g, Some("s3cre")).await, Err(Refusal::Unauthenticated)); + assert_eq!( + run(&g, Some("s3cret2")).await, + Err(Refusal::Unauthenticated) + ); + assert_eq!(run(&g, None).await, Err(Refusal::Unauthenticated)); + let empty = TokenGuard::new(""); + assert_eq!(run(&empty, Some("")).await, Err(Refusal::Unauthenticated)); + } + + #[test] + fn debug_never_shows_the_token() { + let r = GuardRequest::new(GuardSurface::Metrics, Access::Metrics).with_token(Some("x9q")); + assert!(!format!("{r:?}").contains("x9q")); + assert!(!format!("{:?}", TokenGuard::new("x9q")).contains("x9q")); + } + + #[test] + fn secrets_compare_by_content_and_length() { + assert!(same_secret(b"abc", b"abc")); + assert!(!same_secret(b"abc", b"abx")); + assert!(!same_secret(b"abc", b"abcd")); + assert!(!same_secret(b"abcd", b"abc")); + assert!(same_secret(b"", b"")); + } +} diff --git a/crates/krishiv-server/src/internal.rs b/crates/krishiv-server/src/internal.rs index f10ba9213..b1f89a1b7 100644 --- a/crates/krishiv-server/src/internal.rs +++ b/crates/krishiv-server/src/internal.rs @@ -3,8 +3,8 @@ //! //! The server holds no engine state. Everything it shows comes from a [`UiSource`] (the system tables //! `queries`, `operators`, `memory`, `caches`, ...), read as JSON rows once per request, so a handler never -//! takes an engine lock beyond the snapshot the source builds (design T133 item 7). Every endpoint needs the -//! start-up token; columns outside each endpoint's allowlist are dropped, so a table that grows a column does not +//! takes an engine lock beyond the snapshot the source builds (design T133 item 7). Every endpoint asks the +//! router's [`Guard`] (the start-up token, or the server's authentication); columns outside each endpoint's allowlist are dropped, so a table that grows a column does not //! put it on the page by accident. //! //! Timestamps are microseconds since the epoch, as the system tables hold them. @@ -25,6 +25,8 @@ use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use serde_json::{Map, Value, json}; +use crate::guard::{Access, Guard, GuardRequest, GuardSurface, Refusal, TokenGuard, peer_of}; + /// One system-table row as JSON (column name to value). pub type Row = Map; @@ -101,7 +103,7 @@ pub trait UiSource: Send + Sync + 'static { #[derive(Clone)] struct UiState { source: Arc, - token: Arc, + guard: Arc, started: Instant, /// Event streams open now (capped at [`MAX_EVENT_CLIENTS`]). streams: Arc, @@ -148,9 +150,16 @@ async fn security_headers(req: axum::extract::Request, next: Next) -> Response { /// The admin router with the UI backend: [`crate::router`] plus `/api/internal/*`, behind `token`, with the /// security headers on every response. pub fn router_with_ui(source: Arc, token: &str) -> Router { + router_with_ui_guard(source, Arc::new(TokenGuard::new(token))) +} + +/// [`router_with_ui`] behind `guard`: reading other principals' work needs [`Access::Monitor`], a cancel +/// [`Access::Operate`] (ADR 0046 §3: both operator). The token is taken from the same places as with a start-up +/// token, so the page works unchanged with a configured token. +pub fn router_with_ui_guard(source: Arc, guard: Arc) -> Router { let state = UiState { source, - token: token.into(), + guard, started: Instant::now(), streams: Arc::new(AtomicUsize::new(0)), snapshot: Arc::new(tokio::sync::Mutex::new(None)), @@ -196,16 +205,6 @@ fn source_error(e: &UiError) -> Response { ) } -/// Constant-time equality of two tokens. -fn same(a: &str, b: &str) -> bool { - let (a, b) = (a.as_bytes(), b.as_bytes()); - let mut diff = a.len() ^ b.len(); - for i in 0..a.len().max(b.len()) { - diff |= usize::from(a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(0)); - } - diff == 0 -} - /// A query-string parameter's raw value (no percent-decoding: the parameters here are ids, numbers and names). fn param<'a>(uri: &'a Uri, name: &str) -> Option<&'a str> { uri.query()? @@ -245,12 +244,44 @@ async fn require_token( req: axum::extract::Request, next: Next, ) -> Response { - match presented(req.method(), req.headers(), req.uri()) { - Some(t) if same(&t, &s.token) => next.run(req).await, - _ => error( - StatusCode::UNAUTHORIZED, - "unauthorized", - "a valid UI token is required", + let token = presented(req.method(), req.headers(), req.uri()); + let malformed = token.is_none() && req.headers().contains_key(header::AUTHORIZATION); + // Anything but a read changes something: a cancel acts on another principal's query. + let access = if req.method() == Method::GET { + Access::Monitor + } else { + Access::Operate + }; + let request = GuardRequest::new(GuardSurface::Ui, access) + .with_token(token.as_deref()) + .with_malformed(malformed) + .with_peer(peer_of(req.extensions())); + match s.guard.check(request).await { + Ok(()) => next.run(req).await, + Err(Refusal::Unauthenticated) => { + let mut res = error( + StatusCode::UNAUTHORIZED, + "unauthorized", + "a valid UI token is required", + ); + res.headers_mut() + .insert(header::WWW_AUTHENTICATE, HeaderValue::from_static("Bearer")); + res + } + Err(Refusal::Denied) => error( + StatusCode::FORBIDDEN, + "forbidden", + "the principal's role does not allow this", + ), + Err(Refusal::Unavailable) => error( + StatusCode::SERVICE_UNAVAILABLE, + "unavailable", + "authentication is not possible now; retry later", + ), + Err(_) => error( + StatusCode::INTERNAL_SERVER_ERROR, + "misconfigured", + "the server cannot authenticate this request", ), } } diff --git a/crates/krishiv-server/src/lib.rs b/crates/krishiv-server/src/lib.rs index 0a425c59d..24895fe4f 100644 --- a/crates/krishiv-server/src/lib.rs +++ b/crates/krishiv-server/src/lib.rs @@ -2,7 +2,8 @@ //! Admin and UI API server (layer L6). //! //! Serves the embedded SPA at `/` and the versioned REST surface under -//! `/api/v1`. Binds loopback by default (ADR 0024); auth arrives later. +//! `/api/v1`. Binds loopback by default (ADR 0024); guarded routes ask a +//! [`guard::Guard`] (P3-T002). #![forbid(unsafe_code)] @@ -24,8 +25,11 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub mod metrics; pub mod admin; +pub mod guard; pub mod internal; +#[cfg(feature = "session")] +pub mod session_guard; #[cfg(feature = "session")] pub mod session_source; diff --git a/crates/krishiv-server/src/metrics.rs b/crates/krishiv-server/src/metrics.rs index 80a200fa7..932b24548 100644 --- a/crates/krishiv-server/src/metrics.rs +++ b/crates/krishiv-server/src/metrics.rs @@ -13,11 +13,16 @@ use std::sync::Arc; use axum::Router; +use axum::extract::Request; use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum::routing::get; use krishiv_exec::{MetricsSnapshot, prometheus}; +#[cfg(test)] +use crate::guard::same_secret; +use crate::guard::{Access, Guard, GuardRequest, GuardSurface, Refusal, TokenGuard, peer_of}; + /// Provides the snapshot a scrape renders: called once per `GET /// /metrics`, on the request task. Must be cheap: [`MetricsSnapshot`] is /// plain data the registry hands over. @@ -60,31 +65,44 @@ fn unauthorized() -> Response { .into_response() } -/// Whether `a` and `b` are equal, in time that depends only on their lengths. -fn same_secret(a: &[u8], b: &[u8]) -> bool { - let mut diff = a.len() ^ b.len(); - for i in 0..a.len().max(b.len()) { - diff |= usize::from(a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(0)); - } - // `black_box` keeps the compiler from turning the fold into an early exit. The length is not - // secret from a timing observer; the token's bytes are not revealed by the time taken. - std::hint::black_box(diff) == 0 +/// The token of the first `Authorization: Bearer ` header (scheme ignoring ASCII case, one +/// space, the rest verbatim), and whether an `Authorization` header was there without one. +fn bearer(headers: &HeaderMap) -> (Option<&str>, bool) { + let Some(value) = headers.get(header::AUTHORIZATION) else { + return (None, false); + }; + let token = value.to_str().ok().and_then(|v| { + let (scheme, presented) = v.split_once(' ')?; + scheme.eq_ignore_ascii_case("Bearer").then_some(presented) + }); + (token, token.is_none()) } /// Whether `headers` carry `Authorization: Bearer ` for exactly `token`. An empty `token` /// matches nothing: a misconfigured guard refuses everyone. +#[cfg(test)] fn bearer_matches(headers: &HeaderMap, token: &str) -> bool { - if token.is_empty() { - return false; - } - headers - .get(header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(|v| { - let (scheme, presented) = v.split_once(' ')?; - scheme.eq_ignore_ascii_case("Bearer").then_some(presented) - }) - .is_some_and(|presented| same_secret(presented.as_bytes(), token.as_bytes())) + !token.is_empty() + && bearer(headers) + .0 + .is_some_and(|presented| same_secret(presented.as_bytes(), token.as_bytes())) +} + +/// A refusal as `/metrics` answers it: JSON, no detail. +fn refused(refusal: Refusal) -> Response { + let (status, body) = match refusal { + Refusal::Unauthenticated => return unauthorized(), + Refusal::Denied => (StatusCode::FORBIDDEN, "{\"error\":\"forbidden\"}"), + Refusal::Unavailable => ( + StatusCode::SERVICE_UNAVAILABLE, + "{\"error\":\"unavailable\"}", + ), + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + "{\"error\":\"misconfigured\"}", + ), + }; + (status, [(header::CONTENT_TYPE, "application/json")], body).into_response() } /// The admin/UI router with `GET /metrics`: the exposition of @@ -111,20 +129,38 @@ pub fn router_with_metrics_auth( /// router (`krishiv server`'s admin router); auth as /// [`router_with_metrics_auth`]. pub fn metrics_route(enabled: bool, snapshot: SnapshotProvider, token: Option<&str>) -> Router { + let guard = token.map(|t| Arc::new(TokenGuard::new(t)) as Arc); + metrics_route_guarded(enabled, snapshot, guard) +} + +/// The `/metrics` route behind `guard` ([`Access::Metrics`]: every role scrapes, ADR 0046 §3c), +/// or open with `None`. A refusal takes no snapshot; a disabled `/metrics` is a 404 whatever the +/// request carries. +pub fn metrics_route_guarded( + enabled: bool, + snapshot: SnapshotProvider, + guard: Option>, +) -> Router { let router = Router::new(); if !enabled { return router.route("/metrics", get(metrics_disabled)); } - let token: Option> = token.map(Arc::from); router.route( "/metrics", - get(move |headers: HeaderMap| { - let (snapshot, token) = (Arc::clone(&snapshot), token.clone()); + get(move |request: Request| { + let (snapshot, guard) = (Arc::clone(&snapshot), guard.clone()); async move { - match token { - Some(t) if !bearer_matches(&headers, &t) => unauthorized(), - _ => metrics(snapshot).await.into_response(), + if let Some(guard) = guard { + let (token, malformed) = bearer(request.headers()); + let check = GuardRequest::new(GuardSurface::Metrics, Access::Metrics) + .with_token(token) + .with_malformed(malformed) + .with_peer(peer_of(request.extensions())); + if let Err(refusal) = guard.check(check).await { + return refused(refusal); + } } + metrics(snapshot).await.into_response() } }), ) @@ -134,15 +170,6 @@ pub fn metrics_route(enabled: bool, snapshot: SnapshotProvider, token: Option<&s mod tests { use super::*; - #[test] - fn secrets_compare_by_content_and_length() { - assert!(same_secret(b"abc", b"abc")); - assert!(!same_secret(b"abc", b"abx")); - assert!(!same_secret(b"abc", b"abcd")); - assert!(!same_secret(b"abcd", b"abc")); - assert!(same_secret(b"", b"")); - } - #[test] fn only_a_bearer_header_for_the_exact_token_matches() { let with = |value: &str| { diff --git a/crates/krishiv-server/src/session_guard.rs b/crates/krishiv-server/src/session_guard.rs new file mode 100644 index 000000000..37cb8cbef --- /dev/null +++ b/crates/krishiv-server/src/session_guard.rs @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: Apache-2.0 +//! [`SessionGuard`]: the admin listener's [`Guard`] when authentication is configured (P3-T002 +//! design §3.1; ADR 0046 §3, §3c, §8). +//! +//! The presented token goes to the server's one authenticator (`ServerAuth`, the same one the +//! Spark Connect listener uses); the authenticated role is then checked against the ADR 0046 +//! matrix through [`RolePolicy`], so there is no second role table here. An authentication +//! failure is logged and counted by the authenticator; a denial writes one `krishiv::audit` +//! record (principal, role, surface, securable, privilege; never a reason). + +use std::sync::Arc; + +use futures::future::BoxFuture; +use krishiv_session::auth::{Authenticator, Credentials, Role, RolePolicy, Surface}; +use krishiv_session::{PolicyVersion, Privilege, Securable}; + +use crate::guard::{Access, Guard, GuardRequest, GuardSurface, Refusal}; + +/// Authenticates through an [`Authenticator`] and checks the role. +#[derive(Clone)] +pub struct SessionGuard { + auth: Arc, +} + +impl std::fmt::Debug for SessionGuard { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SessionGuard").finish_non_exhaustive() + } +} + +impl SessionGuard { + /// A guard over `auth` (normally the server's `ServerAuth`). + #[must_use] + pub fn new(auth: Arc) -> Self { + Self { auth } + } +} + +/// The securable and privilege an [`Access`] asks for; `None` for any authenticated caller. +fn requirement(access: Access) -> Option<(Securable, Privilege)> { + let system = |name: &str| Securable::System(name.to_owned()); + match access { + Access::Authenticated => None, + Access::Metrics => Some((system("metrics"), Privilege::Monitor)), + Access::Monitor => Some((system("system"), Privilege::Monitor)), + Access::Operate => Some((system("queries"), Privilege::Operate)), + } +} + +fn surface(s: GuardSurface) -> Surface { + match s { + GuardSurface::Metrics => Surface::Metrics, + GuardSurface::Ui => Surface::Ui, + GuardSurface::Admin => Surface::Admin, + } +} + +impl Guard for SessionGuard { + fn check<'a>(&'a self, request: GuardRequest<'a>) -> BoxFuture<'a, Result<(), Refusal>> { + Box::pin(async move { + let surface = surface(request.surface); + let Some(peer) = request.peer else { + tracing::error!( + target: "krishiv::auth", + surface = surface.as_str(), + "no peer address recorded for an authenticated route; refusing" + ); + return Err(Refusal::Misconfigured); + }; + let mut credentials = Credentials::new(peer, surface); + credentials.malformed = request.malformed; + if let Some(token) = request.token { + credentials = credentials.with_bearer(token); + } + let authenticated = match self.auth.authenticate(&credentials).await { + Ok(a) => a, + Err(f) if f.is_retryable() => return Err(Refusal::Unavailable), + Err(_) => return Err(Refusal::Unauthenticated), + }; + let Some((securable, privilege)) = requirement(request.access) else { + return Ok(()); + }; + // Client code is not reachable from these routes, so the UDF role does not matter; + // the strictest value keeps it from ever widening a decision. + let policy = RolePolicy::new(authenticated.role, Role::Admin, PolicyVersion(0)); + if policy.allows(&securable, privilege) { + return Ok(()); + } + tracing::warn!( + target: "krishiv::audit", + principal = %authenticated.principal.name, + role = authenticated.role.map_or("none", Role::as_str), + surface = surface.as_str(), + securable = %securable, + privilege = %privilege, + error_class = "KRISHIV_INSUFFICIENT_PERMISSIONS", + "access denied" + ); + Err(Refusal::Denied) + }) + } +} + +#[cfg(test)] +mod tests { + use std::net::SocketAddr; + + use krishiv_session::auth::{AuthFailure, Authenticated, Method}; + use krishiv_session::{Principal, PrincipalKind}; + + use super::*; + + /// Accepts `viewer`, `operator` and `admin` as tokens naming their role; `none` maps to no + /// role; `slow` is unavailable; anything else is invalid. + struct Fake; + + impl Authenticator for Fake { + fn authenticate<'a>( + &'a self, + c: &'a Credentials<'a>, + ) -> BoxFuture<'a, Result> { + let result = match c.bearer { + None => Err(AuthFailure::Missing), + Some("slow") => Err(AuthFailure::Unavailable), + Some("none") => Ok(Authenticated::new( + Principal::new("n", PrincipalKind::User), + None, + Method::Token, + )), + Some(t) => Role::parse(t) + .map(|r| { + Authenticated::new( + Principal::new(t, PrincipalKind::User), + Some(r), + Method::Token, + ) + }) + .ok_or(AuthFailure::Invalid), + }; + Box::pin(std::future::ready(result)) + } + } + + async fn check(token: Option<&str>, access: Access) -> Result<(), Refusal> { + let peer: SocketAddr = ([127, 0, 0, 1], 9).into(); + let guard = SessionGuard::new(Arc::new(Fake)); + let request = GuardRequest::new(GuardSurface::Ui, access) + .with_token(token) + .with_peer(Some(peer)); + guard.check(request).await + } + + #[tokio::test] + async fn roles_meet_the_matrix() { + use Access::{Authenticated as Any, Metrics, Monitor, Operate}; + for (token, access, expected) in [ + ("viewer", Any, Ok(())), + ("viewer", Metrics, Ok(())), + ("viewer", Monitor, Err(Refusal::Denied)), + ("viewer", Operate, Err(Refusal::Denied)), + ("operator", Metrics, Ok(())), + ("operator", Monitor, Ok(())), + ("operator", Operate, Ok(())), + ("admin", Operate, Ok(())), + ("none", Any, Ok(())), + ("none", Metrics, Err(Refusal::Denied)), + ] { + assert_eq!( + check(Some(token), access).await, + expected, + "{token} {access:?}" + ); + } + } + + #[tokio::test] + async fn failures_map_to_refusals() { + assert_eq!( + check(None, Access::Metrics).await, + Err(Refusal::Unauthenticated) + ); + assert_eq!( + check(Some("bogus"), Access::Metrics).await, + Err(Refusal::Unauthenticated) + ); + assert_eq!( + check(Some("slow"), Access::Metrics).await, + Err(Refusal::Unavailable) + ); + } + + #[tokio::test] + async fn no_peer_fails_closed() { + let guard = SessionGuard::new(Arc::new(Fake)); + let request = + GuardRequest::new(GuardSurface::Metrics, Access::Metrics).with_token(Some("admin")); + assert_eq!(guard.check(request).await, Err(Refusal::Misconfigured)); + } +} diff --git a/crates/krishiv-server/tests/auth.rs b/crates/krishiv-server/tests/auth.rs new file mode 100644 index 000000000..d0dfef6e2 --- /dev/null +++ b/crates/krishiv-server/tests/auth.rs @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: Apache-2.0 +//! The admin listener with authentication configured (P3-T002 design §3.1, ADR 0046 §3, §3c): +//! `/metrics` and the UI over a real socket through `serve_capped`, guarded by a `SessionGuard` +//! over the server's `ServerAuth` with one token per role. +#![cfg(all(unix, feature = "session", feature = "metrics-prometheus"))] +#![expect( + clippy::expect_used, + reason = "integration-test helpers are outside #[test] fns" +)] + +use std::net::SocketAddr; +use std::os::unix::fs::PermissionsExt; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +use krishiv_exec::MetricsSnapshot; +use krishiv_server::admin::{HttpLimits, health_routes, serve_capped, with_limits}; +use krishiv_server::guard::Guard; +use krishiv_server::internal::{Row, SystemTable, UiFuture, UiSource, router_with_ui_guard}; +use krishiv_server::metrics::metrics_route_guarded; +use krishiv_server::session_guard::SessionGuard; +use krishiv_session::auth::{AuthConfig, ServerAuth}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +const VIEWER: &str = "viewer-token-0123456789abcdef0123456789"; +const OPERATOR: &str = "operator-token-0123456789abcdef012345678"; + +struct Empty; + +impl UiSource for Empty { + fn rows(&self, _: SystemTable) -> UiFuture<'_, Vec> { + Box::pin(async { Ok(Vec::new()) }) + } + fn cancel<'a>(&'a self, _: &'a str) -> UiFuture<'a, bool> { + Box::pin(async { Ok(false) }) + } +} + +fn secret(dir: &std::path::Path, name: &str, value: &str) -> String { + let path = dir.join(name); + std::fs::write(&path, value).expect("write secret"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("chmod"); + format!("file:{}", path.display()) +} + +/// The admin router of `krishiv server` with auth on: a viewer and an operator token, and a +/// throttle that blocks a peer after two failures. +async fn start() -> SocketAddr { + let dir = tempfile::tempdir().expect("tempdir"); + let (v, o) = ( + secret(dir.path(), "v", VIEWER), + secret(dir.path(), "o", OPERATOR), + ); + let settings = [ + ("krishiv.auth.tokens.prom.secret", v.as_str()), + ("krishiv.auth.tokens.prom.principal", "prometheus"), + ("krishiv.auth.tokens.prom.kind", "service"), + ("krishiv.auth.tokens.prom.role", "viewer"), + ("krishiv.auth.tokens.ops.secret", o.as_str()), + ("krishiv.auth.tokens.ops.principal", "ann"), + ("krishiv.auth.tokens.ops.role", "operator"), + ("krishiv.auth.failureThrottle.maxFailures", "2"), + ]; + let config = AuthConfig::from_settings(settings).expect("auth config"); + let auth = ServerAuth::new(&config).expect("server auth"); + let guard: Arc = Arc::new(SessionGuard::new(Arc::new(auth))); + let metrics = metrics_route_guarded( + true, + Arc::new(MetricsSnapshot::default), + Some(Arc::clone(&guard)), + ); + let app = router_with_ui_guard(Arc::new(Empty), guard) + .merge(health_routes(Arc::new(AtomicBool::new(true)))) + .merge(metrics); + let limits = HttpLimits::default(); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + tokio::spawn(serve_capped( + listener, + with_limits(app, limits), + limits.max_connections, + std::future::pending(), + )); + addr +} + +/// The raw response to `method path` with `token` as a bearer header (if any). +async fn call(addr: SocketAddr, method: &str, path: &str, token: Option<&str>) -> String { + let mut s = TcpStream::connect(addr).await.expect("connect"); + let auth = token.map_or(String::new(), |t| format!("Authorization: Bearer {t}\r\n")); + let request = format!( + "{method} {path} HTTP/1.1\r\nHost: x\r\n{auth}Content-Length: 0\r\nConnection: close\r\n\r\n" + ); + s.write_all(request.as_bytes()).await.expect("write"); + let mut out = String::new(); + s.read_to_string(&mut out).await.expect("read"); + out +} + +fn status(response: &str) -> &str { + response.get(9..12).unwrap_or("") +} + +#[tokio::test] +async fn each_role_reaches_what_the_matrix_gives_it() { + let addr = start().await; + let queries = "/api/internal/queries"; + let cancel = "/api/internal/queries/q1/cancel"; + // ADR 0046 §3c: a viewer scrapes /metrics, and sees no other principal's work. + assert_eq!( + status(&call(addr, "GET", "/metrics", Some(VIEWER)).await), + "200" + ); + assert_eq!( + status(&call(addr, "GET", queries, Some(VIEWER)).await), + "403" + ); + assert_eq!( + status(&call(addr, "POST", cancel, Some(VIEWER)).await), + "403" + ); + // An operator monitors and acts on others' running work. + assert_eq!( + status(&call(addr, "GET", "/metrics", Some(OPERATOR)).await), + "200" + ); + assert_eq!( + status(&call(addr, "GET", queries, Some(OPERATOR)).await), + "200" + ); + assert_ne!( + status(&call(addr, "POST", cancel, Some(OPERATOR)).await), + "403" + ); + // The configured token works in the UI's query-string form too (GET only). + let page = format!("{queries}?token={OPERATOR}"); + assert_eq!(status(&call(addr, "GET", &page, None).await), "200"); + // The probes stay open. + assert_eq!(status(&call(addr, "GET", "/healthz", None).await), "200"); + assert_eq!(status(&call(addr, "GET", "/readyz", None).await), "200"); +} + +#[tokio::test] +async fn failures_are_401_then_the_peer_is_throttled() { + let addr = start().await; + let missing = call(addr, "GET", "/metrics", None).await; + assert_eq!(status(&missing), "401", "{missing}"); + assert!(missing.contains("www-authenticate: Bearer"), "{missing}"); + let wrong = call(addr, "GET", "/api/internal/queries", Some("not-a-token")).await; + assert_eq!(status(&wrong), "401", "{wrong}"); + assert!(!wrong.contains("not-a-token"), "{wrong}"); + // A second invalid token reaches the throttle (maxFailures = 2): the peer is refused for a + // while, even with a valid token. + assert_eq!( + status(&call(addr, "GET", "/metrics", Some("again")).await), + "401" + ); + assert_eq!( + status(&call(addr, "GET", "/metrics", Some(VIEWER)).await), + "503" + ); + assert_eq!(status(&call(addr, "GET", "/healthz", None).await), "200"); +} From ca8af0bffbb46c23ade4776f7bcfd1be79f458d5 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:55:30 +0000 Subject: [PATCH 23/47] session: the SET store-key check moves to session/ops.rs (command.rs back under 2,000 lines after merging main) Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-session/src/command.rs | 11 +---------- crates/krishiv-session/src/session/ops.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/crates/krishiv-session/src/command.rs b/crates/krishiv-session/src/command.rs index 3aaf5682a..adcd29ce0 100644 --- a/crates/krishiv-session/src/command.rs +++ b/crates/krishiv-session/src/command.rs @@ -145,16 +145,7 @@ impl Session { } .into()); } - // A server-scoped store key is `Manage` on the cluster (P3-T067b), asked before the - // value is stored, so a refused `SET` leaves the configuration as it was. - if krishiv_io::is_server_scoped_option(key) { - self.authorize_store_options( - krishiv_sql::StatementClass::Session, - &self.state.conf.snapshot(), - std::iter::once(key.as_str()), - ) - .await?; - } + self.authorize_set_store_key(key).await?; // The assignment sets through the session configuration, // so validation and error classes are its own; the row // shows the value as written, as in Spark. diff --git a/crates/krishiv-session/src/session/ops.rs b/crates/krishiv-session/src/session/ops.rs index c567e0f0e..ee682d47e 100644 --- a/crates/krishiv-session/src/session/ops.rs +++ b/crates/krishiv-session/src/session/ops.rs @@ -95,6 +95,20 @@ impl Session { crate::access::audit(self, class, r) } + /// `SET key = ..` of a server-scoped store key is `Manage` on the cluster (P3-T067b); any + /// other key passes. Asked before the value is stored, so a refused `SET` changes nothing. + pub(crate) async fn authorize_set_store_key(&self, key: &str) -> Result<(), KrishivError> { + if !krishiv_io::is_server_scoped_option(key) { + return Ok(()); + } + self.authorize_store_options( + StatementClass::Session, + &self.state.conf.snapshot(), + std::iter::once(key), + ) + .await + } + /// The file format named `format`, which must be one the engine writes: /// the Spark file sources it does not write fail with /// `KRISHIV_UNSUPPORTED_FEATURE`. From 6895824880d07c99abbcbdc5205b9538c0a0262b Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Fri, 13 Nov 2026 18:13:09 +0000 Subject: [PATCH 24/47] server, cli, ci: review fixes: metrics always guarded with auth on, Connect warning, feature-gated auth tests in the PR gate - krishiv.server.metrics.public opens /metrics only without authentication (S2) - krishiv server warns that Spark Connect does not check tokens yet (S1) - ci: krishiv-server (session, metrics-prometheus) and the CLI server tests run in the PR gate (S6) - tests: exact cancel status, a query-string token on POST is 401, the CLI UI check per feature Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- .github/workflows/ci.yml | 3 +++ crates/krishiv-cli/src/server.rs | 23 ++++++++++++++++++++--- crates/krishiv-cli/tests/server.rs | 14 ++++++++------ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ae4f0035..3a4ac6f18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -290,6 +290,9 @@ jobs: KRISHIV_SIM_TPCH_DIR: ${{ runner.temp }}/tpch-sf0.1 - run: cargo test --workspace --doc --locked - run: cargo nextest run -p krishiv-session --features telemetry-otlp --test otlp_collector --locked + # The admin listener's authentication (P3-T002) is feature-gated, so default-feature runs skip it. + - run: cargo nextest run -p krishiv-server --features session,metrics-prometheus --locked + - run: cargo nextest run -p krishiv-cli --features server --test server --locked - name: Disk left if: always() run: df -h / "$RUNNER_TEMP" diff --git a/crates/krishiv-cli/src/server.rs b/crates/krishiv-cli/src/server.rs index 25eb386c0..76de5eb55 100644 --- a/crates/krishiv-cli/src/server.rs +++ b/crates/krishiv-cli/src/server.rs @@ -160,7 +160,8 @@ fn flag(config: &Config, entry: &krishiv_common::config::ConfigEntry, default: b #[expect( clippy::print_stdout, - reason = "CLI binary: the addresses and token are its output" + clippy::print_stderr, + reason = "CLI binary: the addresses, token and warnings are its output" )] async fn run_listeners( runtime: Arc, @@ -196,7 +197,14 @@ async fn run_listeners( Arc::new(TokenGuard::new(&token)) }; let ready = Arc::new(AtomicBool::new(false)); - let app = admin_router(&runtime, config, guard, Arc::clone(&ready), admin_bound)?; + let app = admin_router( + &runtime, + config, + guard, + authenticated, + Arc::clone(&ready), + admin_bound, + )?; let connect = ConnectServer::new( Arc::clone(&manager), ConnectServerConfig::from_config(config), @@ -222,6 +230,12 @@ async fn run_listeners( let ui = flag(config, &UI_ENABLED, true) && cfg!(feature = "ui"); if authenticated { println!("krishiv server: admin routes need a configured token (krishiv.auth.tokens)"); + // The Connect listener is authenticated in a later unit; until then it serves any + // local client, which the loopback-only rule above keeps to this machine. + eprintln!( + "krishiv server: warning: Spark Connect does not check tokens yet; it accepts any \ + client on {connect_bound}" + ); } else if ui { println!("open http://{admin_bound}/?token={token}"); } else if metrics_need_token(config, admin_bound) { @@ -252,11 +266,14 @@ fn admin_router( runtime: &Arc, config: &Config, guard: Arc, + authenticated: bool, ready: Arc, bound: SocketAddr, ) -> Result { let metrics_on = flag(config, &METRICS_ENABLED, false); - let public = metrics_on && !metrics_need_token(config, bound); + // With authentication configured every scrape authenticates (ADR 0046 §3c: a viewer + // service principal); `krishiv.server.metrics.public` opens it only without. + let public = metrics_on && !authenticated && !metrics_need_token(config, bound); let snapshot_runtime = Arc::clone(runtime); let metrics = krishiv_server::metrics::metrics_route_guarded( metrics_on, diff --git a/crates/krishiv-cli/tests/server.rs b/crates/krishiv-cli/tests/server.rs index a5b5fabe9..5371b251f 100644 --- a/crates/krishiv-cli/tests/server.rs +++ b/crates/krishiv-cli/tests/server.rs @@ -333,13 +333,15 @@ fn configured_tokens_guard_the_admin_routes() { assert!(none.starts_with("HTTP/1.1 401"), "{none}"); let scrape = http_get_as(&server.admin, "/metrics", Some(token)); assert!(scrape.starts_with("HTTP/1.1 200"), "{scrape}"); - // A viewer sees no other principal's work in the UI's data routes (403 with the UI on; the - // route does not exist without it). + // A viewer sees no other principal's work in the UI's data routes (the route exists only + // with the `ui` feature). let ui = http_get_as(&server.admin, "/api/internal/queries", Some(token)); - assert!( - ui.starts_with("HTTP/1.1 403") || ui.starts_with("HTTP/1.1 404"), - "{ui}" - ); + let expected = if cfg!(feature = "ui") { + "HTTP/1.1 403" + } else { + "HTTP/1.1 404" + }; + assert!(ui.starts_with(expected), "{ui}"); let probe = http_get(&server.admin, "/healthz"); assert!(probe.starts_with("HTTP/1.1 200"), "{probe}"); } From 5b2581ca6f6a1181b85ef0984807ce837e0f3c76 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:11:33 +0000 Subject: [PATCH 25/47] connect-server, session, cli: Spark Connect authentication and request classes (P3-T002 PR-14, gRPC side) - Every Connect RPC authenticates through the server's one ServerAuth (ConnectAuth), shared with the admin listener: the authorization metadata and the peer address; Spark's UNAUTHENTICATED messages, UNAVAILABLE when throttled or the IdP is down, no peer address refused. Unimplemented RPCs authenticate before UNIMPLEMENTED. - Sessions are keyed by SessionOwner (method, kind, tenant, name; no groups), the proposed answer to design note section 7. A caller whose role differs from the role its session was built with never gets that session: it is closed and replaced, and a client that saw it gets INVALID_HANDLE.SESSION_CHANGED. - check_rpc / check_command: the ADR 0046 section 3b class of every RPC and Command arm, without a wildcard arm. ExecutePlan checks a command's class (Session::authorize_privilege, audited) before the 56K00 refusal commands get until T012. - Session::new_session_as; krishiv server builds each caller's session under PathPrefixPolicy(RolePolicy(role)) and drops the "Connect does not check tokens" warning. Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-cli/CLAUDE.md | 2 +- crates/krishiv-cli/src/server.rs | 63 +++- crates/krishiv-connect-server/CLAUDE.md | 26 +- crates/krishiv-connect-server/src/auth.rs | 336 +++++++++++++++++ .../krishiv-connect-server/src/auth/tests.rs | 314 ++++++++++++++++ crates/krishiv-connect-server/src/execute.rs | 52 ++- crates/krishiv-connect-server/src/lib.rs | 6 +- crates/krishiv-connect-server/src/manager.rs | 221 ++++++----- .../src/manager/tests.rs | 130 ++++++- crates/krishiv-connect-server/src/server.rs | 15 +- crates/krishiv-connect-server/src/service.rs | 99 ++--- crates/krishiv-connect-server/tests/auth.rs | 348 ++++++++++++++++++ .../krishiv-connect-server/tests/execute.rs | 28 +- crates/krishiv-connect-server/tests/server.rs | 18 +- crates/krishiv-session/CLAUDE.md | 4 +- crates/krishiv-session/public-api.txt | 11 +- crates/krishiv-session/src/access.rs | 23 ++ .../krishiv-session/src/session/lifecycle.rs | 23 ++ 18 files changed, 1525 insertions(+), 194 deletions(-) create mode 100644 crates/krishiv-connect-server/src/auth.rs create mode 100644 crates/krishiv-connect-server/src/auth/tests.rs create mode 100644 crates/krishiv-connect-server/tests/auth.rs diff --git a/crates/krishiv-cli/CLAUDE.md b/crates/krishiv-cli/CLAUDE.md index b7479dcf7..e711d660a 100644 --- a/crates/krishiv-cli/CLAUDE.md +++ b/crates/krishiv-cli/CLAUDE.md @@ -10,7 +10,7 @@ The `krishiv` binary: sql, server, coordinator, worker, mcp, ui, doctor, migrate Paths are relative to this crate. - `src/main.rs` subcommand dispatch and the allocator/memory hook set-up - `src/sql.rs` the `krishiv sql` subcommand: input (`-e`, `-f`, stdin), output (`-o`), `--conf`, `--table`, `--timing`, `--agent`, `--max-rows`, `--max-seconds` -- `src/server.rs` `krishiv server` (feature `server`; `ui` implies it; design P3-T001): host checks before binding (non-loopback refused until P3-T002, root refused without `--allow-root`, core dumps off unless `krishiv.server.allowCoreDumps`), one tokio runtime for the Spark Connect listener (`krishiv-connect-server`, `--connect-port`, default 15002) and the admin listener (`--port` 8080: `/healthz`, `/readyz`, `/metrics` and the UI with `ui`; with `krishiv.auth.*` tokens configured they are guarded by `SessionGuard` over one `ServerAuth`, else by a random token printed once; `krishiv.auth.*` settings never reach the engine configuration), SIGTERM/SIGINT drain then exit 0 after the engine runtime is dropped, a second signal exits 130; `tests/server.rs` +- `src/server.rs` `krishiv server` (feature `server`; `ui` implies it; design P3-T001): host checks before binding (non-loopback refused until P3-T002, root refused without `--allow-root`, core dumps off unless `krishiv.server.allowCoreDumps`), one tokio runtime for the Spark Connect listener (`krishiv-connect-server`, `--connect-port`, default 15002) and the admin listener (`--port` 8080: `/healthz`, `/readyz`, `/metrics` and the UI with `ui`; with `krishiv.auth.*` tokens configured they are guarded by `SessionGuard` over one `ServerAuth`, else by a random token printed once; `krishiv.auth.*` settings never reach the engine configuration). With authentication on, the same `ServerAuth` authenticates every Connect RPC (`ConnectAuth`) and each caller's session is `Session::new_session_as(principal, PathPrefixPolicy(RolePolicy(role)))` (`caller_sessions`; client code admin-only until T014b, ADR 0046 N1; the path confinement is checked at startup), SIGTERM/SIGINT drain then exit 0 after the engine runtime is dropped, a second signal exits 130; `tests/server.rs` - `src/check_compat/` (P3-T029) `krishiv check-compat `: `mod.rs` args, file walk, exit codes (0 none, 1 findings, 2 read errors), JSON/text report; `scan.rs` SQL / Python / notebook-cell scanners; `alternatives.json` workarounds for not-yet functions (names only supported functions; a unit test checks), `functions.json` the function matrix (generated from the SQL reference pages by `tests/scripts/gen_compat_matrix.py`); corpus `tests/check_compat_corpus/` (`handwritten/` with a hand-checked `expected.json`, `supported_only/` must give no findings, `generated/` from `tests/scripts/gen_check_compat_corpus.py`); JSON report is `{schema_version, files_scanned, findings, errors}` - `src/repl.rs` the interactive loop (no `-e`/`-f` on a terminal): `.timer`, `.format`, `.complete`, `.history`, history persistence - `src/complete.rs` completion sources (keywords, function registry, session catalog, config registry) diff --git a/crates/krishiv-cli/src/server.rs b/crates/krishiv-cli/src/server.rs index 76de5eb55..1d5a7330c 100644 --- a/crates/krishiv-cli/src/server.rs +++ b/crates/krishiv-cli/src/server.rs @@ -25,12 +25,12 @@ use krishiv_common::config::{ SERVER_METRICS_PUBLIC, UI_ENABLED, }; use krishiv_connect_server::{ - ConnectServer, ConnectServerConfig, SessionLimits, SessionManager, host, + ConnectAuth, ConnectServer, ConnectServerConfig, SessionLimits, SessionManager, host, }; use krishiv_server::guard::{Guard, TokenGuard}; use krishiv_server::session_guard::SessionGuard; -use krishiv_session::auth::{AuthConfig, ServerAuth, is_auth_key}; -use krishiv_session::{EngineRuntime, Session}; +use krishiv_session::auth::{AuthConfig, Authenticated, Role, RolePolicy, ServerAuth, is_auth_key}; +use krishiv_session::{AccessPolicy, EngineRuntime, PathPrefixPolicy, PolicyVersion, Session}; use tokio::net::TcpListener; use tokio::sync::watch; @@ -151,6 +151,31 @@ fn serve(args: &ServerArgs) -> Result<(), String> { result } +/// The session factory of an authenticated server: each caller's session acts for its +/// principal under `PathPrefixPolicy(RolePolicy(role))` (design P3-T002 §3.6), on `base`'s +/// runtime. Client code needs admin until the T014b sandbox merges (ADR 0046 N1). The path +/// confinement is checked once here, so a bad `krishiv.io.*` root fails at startup. +fn caller_sessions( + base: Session, + config: &Config, +) -> Result, String> { + let confine = { + let config = config.clone(); + move |inner: Arc| PathPrefixPolicy::from_config(&config, inner) + }; + confine(Arc::new(RolePolicy::new( + None, + Role::Admin, + PolicyVersion(0), + ))) + .map_err(|e| e.to_string())?; + Ok(Box::new(move |caller: &Authenticated| { + let role = RolePolicy::new(caller.role, Role::Admin, PolicyVersion(0)); + let policy = confine(Arc::new(role))?; + Ok(base.new_session_as(caller.principal.clone(), Arc::new(policy))) + })) +} + fn flag(config: &Config, entry: &krishiv_common::config::ConfigEntry, default: bool) -> bool { config .get(entry) @@ -160,8 +185,7 @@ fn flag(config: &Config, entry: &krishiv_common::config::ConfigEntry, default: b #[expect( clippy::print_stdout, - clippy::print_stderr, - reason = "CLI binary: the addresses, token and warnings are its output" + reason = "CLI binary: the addresses and the token are its output" )] async fn run_listeners( runtime: Arc, @@ -174,10 +198,14 @@ async fn run_listeners( .runtime(Arc::clone(&runtime)) .build() .map_err(|e| e.to_string())?; - let manager = Arc::new(SessionManager::from_base( - base, - SessionLimits::from_config(config), - )); + let authenticated = auth.is_enabled(); + let auth = Arc::new(auth); + let limits = SessionLimits::from_config(config); + let manager = Arc::new(if authenticated { + SessionManager::new(caller_sessions(base, config)?, limits) + } else { + SessionManager::from_base(base, limits) + }); let connect_listener = TcpListener::bind(connect_addr) .await .map_err(|e| format!("cannot bind the Spark Connect port {connect_addr}: {e}"))?; @@ -189,10 +217,9 @@ async fn run_listeners( // With authentication configured the admin routes take the same principals as Connect; // without it, a random token printed once (loopback only). - let authenticated = auth.is_enabled(); let token = krishiv_server::internal::random_token().map_err(|e| e.to_string())?; let guard: Arc = if authenticated { - Arc::new(SessionGuard::new(Arc::new(auth))) + Arc::new(SessionGuard::new(Arc::clone(&auth) as Arc<_>)) } else { Arc::new(TokenGuard::new(&token)) }; @@ -205,10 +232,13 @@ async fn run_listeners( Arc::clone(&ready), admin_bound, )?; - let connect = ConnectServer::new( + let mut connect = ConnectServer::new( Arc::clone(&manager), ConnectServerConfig::from_config(config), ); + if authenticated { + connect = connect.with_auth(ConnectAuth::new(auth)); + } let (stop_tx, stop_rx) = watch::channel(false); // Installed before the addresses are printed: a SIGTERM right after @@ -229,12 +259,9 @@ async fn run_listeners( println!("krishiv server: admin on http://{admin_bound}"); let ui = flag(config, &UI_ENABLED, true) && cfg!(feature = "ui"); if authenticated { - println!("krishiv server: admin routes need a configured token (krishiv.auth.tokens)"); - // The Connect listener is authenticated in a later unit; until then it serves any - // local client, which the loopback-only rule above keeps to this machine. - eprintln!( - "krishiv server: warning: Spark Connect does not check tokens yet; it accepts any \ - client on {connect_bound}" + println!( + "krishiv server: Spark Connect and the admin routes need a configured credential \ + (krishiv.auth.*)" ); } else if ui { println!("open http://{admin_bound}/?token={token}"); diff --git a/crates/krishiv-connect-server/CLAUDE.md b/crates/krishiv-connect-server/CLAUDE.md index 534e529e8..64d9df72c 100644 --- a/crates/krishiv-connect-server/CLAUDE.md +++ b/crates/krishiv-connect-server/CLAUDE.md @@ -4,8 +4,8 @@ **Real code since:** P3-T001 (design `../krishiv-internal/design/P3-T001-server.md`, approved 2026-11-12). ## Purpose -The Spark Connect gRPC server: the tonic service, the session manager keyed by (authenticated principal, session -id), the limits applied before admission, and graceful shutdown. Decoding and translation stay in `krishiv-connect`; +The Spark Connect gRPC server: the tonic service, the authentication of every RPC, the session manager keyed by +(authenticated caller, session id), the limits applied before admission, and graceful shutdown. Decoding and translation stay in `krishiv-connect`; the admin HTTP server (`/healthz`, `/readyz`, `/metrics`, UI) is `krishiv-server`; the process around both is `krishiv server` in `krishiv-cli`. @@ -16,6 +16,14 @@ Paths are relative to this crate. - `src/service.rs` `ConnectService`, Krishiv's implementation: `ExecutePlan`, the minimal `ReattachExecute` / `ReleaseExecute` (design P3-T011a §3.4: no replay buffer until PR-15a), `ReleaseSession`; the other RPCs are `UNIMPLEMENTED` until PR-11b/PR-15; a draining server answers `UNAVAILABLE` ("server shutting down") +- `src/auth.rs` (P3-T002, design `design/P3-T002-authentication.md` §3.1/§3.6, ADR 0046 §3b/§5) `ConnectAuth`: + every RPC (the unimplemented ones too) authenticates through the server's one `Authenticator` (`ServerAuth`, + shared with the admin listener): the `authorization` metadata (two entries or a non-`Bearer` scheme are + malformed) and the peer address; failures `UNAUTHENTICATED` with Spark's messages, throttled or unavailable + `UNAVAILABLE`, no peer address refused; auth off = the local user. `SessionOwner` (method, kind, tenant, name, + no groups) is the session key. `check_rpc` / `check_command`: the §3b class of every RPC and `Command` arm, + no wildcard arm (a new proto arm fails to compile); `enforce` asks the session's policy for a + `Check::Privilege` (`Session::authorize_privilege`, one `krishiv::audit` record per denial); `tests/auth.rs` - `src/operation.rs` `Operation` (one per `ExecutePlan`: id, plan digest, tags, reattachable, sent response ids, whether its encoder waits for the client, cancel) and `Operations`, the per-session map in `SessionEntry` (`krishiv.connect.maxOperationsPerSession`, @@ -32,7 +40,8 @@ Paths are relative to this crate. `exception_classes` picks the Java class list PySpark's `convert_exception` reads (Spark's own for the Connect classes, else the Python facade's class/SQLSTATE rules); `KRISHIV_SESSION_LIMIT` and `KRISHIV_OPERATION_LIMIT` are `RESOURCE_EXHAUSTED` -- `src/manager.rs` `SessionManager` (Spark's `SparkConnectSessionManager`): get-or-create / lookup / release, tombstones +- `src/manager.rs` `SessionManager` (Spark's `SparkConnectSessionManager`; its factory gets the `Authenticated` + caller): get-or-create / lookup / release, tombstones (LRU, no expiry), `SESSION_CHANGED` on a changed server-side id, the idle sweep (skips a session running a query or holding a live operation; `busy()` is what shutdown waits for), `krishiv.connect.maxSessions` / `maxSessionsPerPrincipal` @@ -40,9 +49,14 @@ Paths are relative to this crate. - `tests/server.rs`, `tests/execute.rs` end to end over a raw tonic client ## Invariants -- **Sessions are keyed by the authenticated principal**, never by `UserContext.user_id` (ADR 0046 §5, threat model - T-A8); a session id another principal holds is `INVALID_HANDLE.SESSION_NOT_FOUND`, the same answer as unknown. - Until T002 the principal is a request extension set by the auth layer, else the local user (loopback only). +- **Sessions are keyed by the authenticated caller's `SessionOwner`**, never by `UserContext.user_id` (ADR 0046 + §5, threat model T-A8); a session id another owner holds is `INVALID_HANDLE.SESSION_NOT_FOUND`, the same answer + as unknown. Groups are not in the key; the method is (a token and an OIDC subject of one name are two owners). +- **A session's policy is its caller's role at creation, for life**: a caller whose role differs never gets that + session; it is closed without a tombstone and the lookup goes on as for a free id (a create builds a new one, + a client that saw the old id gets `INVALID_HANDLE.SESSION_CHANGED`). `ReleaseSession` ends it whatever the role. +- **Every RPC authenticates before anything else but the drain check**, once per RPC (tokens expire, connections + outlive them). - **No lock across an `await`**, and none while a session is built or closed: the manager's mutex guards map lookups and inserts only. A racing create of the same key keeps the first and closes the loser. - **Decode only through `wire`** (`#![deny(clippy::disallowed_methods)]`): the codec is the one decode path. diff --git a/crates/krishiv-connect-server/src/auth.rs b/crates/krishiv-connect-server/src/auth.rs new file mode 100644 index 000000000..b4c3bb7f4 --- /dev/null +++ b/crates/krishiv-connect-server/src/auth.rs @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Authentication and request classes of the Spark Connect listener (P3-T002; design +//! `design/P3-T002-authentication.md` §3.1 and §3.6, ADR 0046 §3b and §5). +//! +//! - [`ConnectAuth::caller`] authenticates **every RPC**, not each connection: tokens expire +//! and an HTTP/2 connection outlives them. It hands the bearer token of the `authorization` +//! metadata and the peer address (the failure throttle's key) to the server's one +//! [`Authenticator`]. A failure is `UNAUTHENTICATED` with Spark's messages; a throttled peer +//! or an unreachable OIDC provider is `UNAVAILABLE`, which clients retry. A request without +//! a peer address is refused (fail closed). With authentication off every request is the +//! server's local user, which the loopback-only listener rule keeps to this machine. +//! - [`SessionOwner`] is what a Connect session is bound to: the method, the principal's kind, +//! tenant and name, **without its groups**. An OIDC user whose group claim changes between +//! two tokens keeps its sessions, and a static token and an OIDC subject with the same name +//! never share one (design §7, open question; this is the proposed answer). A session's +//! policy is fixed for its life, so a caller whose **role** differs from the role its +//! session was built with does not get that session: the session manager replaces it +//! (`crate::manager`). +//! - [`check_rpc`] and [`check_command`] give every RPC and every `Command` arm its ADR 0046 +//! §3b class. Neither has a wildcard arm, so a proto update that adds an RPC or a command +//! does not compile until the new arm is classified. + +use std::sync::Arc; + +use krishiv_common::error::KrishivError; +use krishiv_common::principal::{Principal, PrincipalKind}; +use krishiv_connect::proto::v4_3 as pb; +use krishiv_connect::proto::v4_3::command::CommandType; +use krishiv_connect::proto::v4_3::ml_command::Command as MlCommandType; +use krishiv_session::auth::{ + AuthFailure, Authenticated, Authenticator, Credentials, Method, Surface, +}; +use krishiv_session::{Privilege, Securable, Session, StatementClass}; +use tonic::{Request, Status}; + +/// The `system` securable a principal needs `Usage` on to send code the server runs (ADR +/// 0046 §4). +const SYSTEM_UDF: &str = "udf"; + +/// The catalog the `global_temp` namespace belongs to, whatever catalog is current. +const SESSION_CATALOG: &str = "spark_catalog"; + +/// The namespace global temporary views live in. +const GLOBAL_TEMP: &str = "global_temp"; + +/// Who each Connect request acts for. +pub struct ConnectAuth { + authenticator: Option>, + local: Principal, +} + +impl std::fmt::Debug for ConnectAuth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ConnectAuth") + .field("enabled", &self.is_enabled()) + .field("local", &self.local) + .finish() + } +} + +impl Default for ConnectAuth { + fn default() -> Self { + Self::off() + } +} + +impl ConnectAuth { + /// Authentication off: every request is the server's local user (loopback only). + #[must_use] + pub fn off() -> Self { + Self { + authenticator: None, + local: Principal::local_user(), + } + } + + /// Every request authenticated by `authenticator` (the server's one `ServerAuth`, shared + /// with the admin listener). + #[must_use] + pub fn new(authenticator: Arc) -> Self { + Self { + authenticator: Some(authenticator), + local: Principal::local_user(), + } + } + + /// Whether requests are authenticated. + #[must_use] + pub fn is_enabled(&self) -> bool { + self.authenticator.is_some() + } + + /// The caller of `request`. + /// + /// # Errors + /// `UNAUTHENTICATED` for a missing, malformed, wrong or expired credential (and for a + /// request without a peer address); `UNAVAILABLE` for a throttled peer or a credential that + /// cannot be checked now. + pub async fn caller(&self, request: &Request) -> Result { + let Some(authenticator) = &self.authenticator else { + return Ok(Authenticated::new(self.local.clone(), None, Method::None)); + }; + let Some(peer) = request.remote_addr() else { + tracing::warn!( + target: "krishiv::auth", + surface = Surface::Connect.as_str(), + "refused a Connect request without a peer address" + ); + return Err(failure_status(AuthFailure::Missing)); + }; + let mut credentials = Credentials::new(peer, Surface::Connect); + let mut values = request.metadata().get_all("authorization").iter(); + match (values.next(), values.next()) { + (None, _) => {} + // One header, readable as text: its `Bearer` token, if it is one. + (Some(value), None) => match value.to_str() { + Ok(text) => credentials = credentials.with_authorization_header(text), + Err(_) => credentials.malformed = true, + }, + // Two `authorization` entries: which one a proxy or the client meant is unknown. + (Some(_), Some(_)) => credentials.malformed = true, + } + authenticator + .authenticate(&credentials) + .await + .map_err(failure_status) + } +} + +/// The gRPC status of an authentication failure (the failure is logged and counted by the +/// authenticator; the status never carries the credential). +fn failure_status(failure: AuthFailure) -> Status { + if failure.is_retryable() { + Status::unavailable(failure.message()) + } else { + Status::unauthenticated(failure.message()) + } +} + +/// What a Connect session is bound to (module docs): the session manager's key. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SessionOwner { + method: Method, + kind: PrincipalKind, + tenant: Option, + name: String, +} + +impl SessionOwner { + /// The owner `caller`'s sessions belong to. + #[must_use] + pub fn of(caller: &Authenticated) -> Self { + Self { + method: caller.method, + kind: caller.principal.kind, + tenant: caller.principal.tenant.clone(), + name: caller.principal.name.clone(), + } + } +} + +/// A Spark Connect RPC (`spark.connect.SparkConnectService`, Spark 4.3.0). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Rpc { + /// `ExecutePlan`; its commands are classified by [`check_command`]. + ExecutePlan, + /// `AnalyzePlan`. + AnalyzePlan, + /// `Config`. + Config, + /// `AddArtifacts`. + AddArtifacts, + /// `ArtifactStatus`. + ArtifactStatus, + /// `Interrupt`. + Interrupt, + /// `ReattachExecute`. + ReattachExecute, + /// `ReleaseExecute`. + ReleaseExecute, + /// `ReleaseSession`. + ReleaseSession, + /// `FetchErrorDetails`. + FetchErrorDetails, + /// `CloneSession`. + CloneSession, + /// `GetStatus`. + GetStatus, +} + +/// What a request is checked as (ADR 0046 §3b). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Check { + /// Nothing beyond the session lookup, which is keyed by the caller: its own sessions and + /// operations, every role. + OwnSession, + /// The securables of the resolved plan, which the session's analyzer asks the policy + /// about when it runs the plan (and, for SQL text, the statement classifier, statement by + /// statement). + Plan, + /// The `Write` class: `Modify` on the target, plus `Create` when the save mode makes one, + /// plus the source plan's securables, through the session's write path (P3-T012 runs it). + Write, + /// `privilege` on `securable`, recorded as `class` when denied, before anything runs. + Privilege { + /// What is asked about. + securable: Securable, + /// The privilege needed. + privilege: Privilege, + /// The statement class an audit record names. + class: StatementClass, + }, + /// Refused for every role: not supported in P3 (ADR 0046 §3b); `what` names it. + Unsupported(&'static str), +} + +fn client_code() -> Check { + Check::Privilege { + securable: Securable::System(SYSTEM_UDF.to_owned()), + privilege: Privilege::Usage, + class: StatementClass::Session, + } +} + +/// The class of `rpc` (ADR 0046 §3b). `ExecutePlan` and `AnalyzePlan` are [`Check::Plan`]: a +/// command is classified further by [`check_command`]. +#[must_use] +pub fn check_rpc(rpc: Rpc) -> Check { + match rpc { + Rpc::ExecutePlan | Rpc::AnalyzePlan => Check::Plan, + // Code the server may run: `Usage` on `System("udf")`. + Rpc::AddArtifacts => client_code(), + // A `Config.set` of an admin key needs `Manage` on the cluster: the key's + // `SettableBy`, checked by the same path as SQL `SET`. + Rpc::Config + | Rpc::ArtifactStatus + | Rpc::Interrupt + | Rpc::ReattachExecute + | Rpc::ReleaseExecute + | Rpc::ReleaseSession + | Rpc::FetchErrorDetails + | Rpc::CloneSession + | Rpc::GetStatus => Check::OwnSession, + } +} + +/// The class of `command` (ADR 0046 §3b). A command without a type is [`Check::Plan`]: the +/// translation refuses it. +#[must_use] +pub fn check_command(command: &pb::Command) -> Check { + let Some(command) = &command.command_type else { + return Check::Plan; + }; + match command { + CommandType::SqlCommand(_) | CommandType::CheckpointCommand(_) => Check::Plan, + CommandType::WriteOperation(_) + | CommandType::WriteOperationV2(_) + | CommandType::MergeIntoTableCommand(_) => Check::Write, + CommandType::CreateDataframeView(view) => { + if view.is_global { + // A shared object: the `Ddl` class, as `CREATE GLOBAL TEMPORARY VIEW`. + Check::Privilege { + securable: Securable::Namespace(vec![ + SESSION_CATALOG.to_owned(), + GLOBAL_TEMP.to_owned(), + ]), + privilege: Privilege::Create, + class: StatementClass::Ddl, + } + } else { + // A local temporary view: its plan is checked when it is read. + Check::OwnSession + } + } + CommandType::RegisterFunction(_) + | CommandType::RegisterTableFunction(_) + | CommandType::RegisterDataSource(_) => client_code(), + CommandType::GetResourcesCommand(_) | CommandType::RemoveCachedRemoteRelationCommand(_) => { + Check::OwnSession + } + CommandType::MlCommand(ml) => match &ml.command { + // A stock client sends it before every test and at exit: nothing to clean. + Some(MlCommandType::CleanCache(_)) => Check::OwnSession, + Some( + MlCommandType::Fit(_) + | MlCommandType::Fetch(_) + | MlCommandType::Delete(_) + | MlCommandType::Write(_) + | MlCommandType::Read(_) + | MlCommandType::Evaluate(_) + | MlCommandType::GetCacheInfo(_) + | MlCommandType::CreateSummary(_) + | MlCommandType::GetModelSize(_), + ) + | None => Check::Unsupported("ml_command"), + }, + CommandType::CreateResourceProfileCommand(_) => { + Check::Unsupported("create_resource_profile_command") + } + CommandType::ExecuteExternalCommand(_) => Check::Unsupported("execute_external_command"), + CommandType::PipelineCommand(_) => Check::Unsupported("pipeline_command"), + CommandType::WriteStreamOperationStart(_) + | CommandType::StreamingQueryCommand(_) + | CommandType::StreamingQueryManagerCommand(_) + | CommandType::StreamingQueryListenerBusCommand(_) => { + Check::Unsupported("streaming commands") + } + // No extension is registered until P3-T015; one without a declared class is refused. + CommandType::Extension(_) => Check::Unsupported("extension"), + } +} + +/// Applies the part of `check` that is decided before a request runs: a +/// [`Check::Privilege`] is asked of `session`'s policy. The other classes are applied where +/// the request runs (the session lookup, the analyzer, the write path) or refused by it. +/// +/// # Errors +/// `KRISHIV_INSUFFICIENT_PERMISSIONS` (42501) when the session's principal lacks the +/// privilege. +pub async fn enforce(check: &Check, session: &Session) -> Result<(), KrishivError> { + match check { + Check::Privilege { + securable, + privilege, + class, + } => { + session + .authorize_privilege(securable, *privilege, *class) + .await + } + Check::OwnSession | Check::Plan | Check::Write | Check::Unsupported(_) => Ok(()), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/krishiv-connect-server/src/auth/tests.rs b/crates/krishiv-connect-server/src/auth/tests.rs new file mode 100644 index 000000000..47b7183a7 --- /dev/null +++ b/crates/krishiv-connect-server/src/auth/tests.rs @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: Apache-2.0 +use std::io::Write; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::Path; +use std::sync::Arc; + +use krishiv_common::principal::PrincipalKind; +use krishiv_connect::proto::v4_3 as pb; +use krishiv_connect::proto::v4_3::command::CommandType; +use krishiv_connect::proto::v4_3::ml_command::Command as MlCommandType; +use krishiv_session::auth::{AuthConfig, Method, Role, ServerAuth}; +use krishiv_session::{Privilege, Securable, StatementClass}; +use tonic::transport::server::TcpConnectInfo; +use tonic::{Code, Request}; + +use super::{Check, ConnectAuth, Rpc, SessionOwner, check_command, check_rpc}; + +const TOKEN: &str = "cccccccccccccccccccccccccccccccccccccccc"; + +#[expect( + clippy::disallowed_methods, + reason = "test setup: the secret file a token config reads" +)] +fn secret_file(dir: &Path, contents: &str) -> String { + let path = dir.join("token"); + let mut f = std::fs::File::create(&path).expect("create"); + f.write_all(contents.as_bytes()).expect("write"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("mode"); + } + format!("file:{}", path.display()) +} + +/// A `ConnectAuth` over a real `ServerAuth` with one token: `carol`, viewer. +fn token_auth(dir: &Path) -> ConnectAuth { + let secret = secret_file(dir, TOKEN); + let config = AuthConfig::from_settings([ + ("krishiv.auth.tokens.c.secret", secret.as_str()), + ("krishiv.auth.tokens.c.principal", "carol"), + ("krishiv.auth.tokens.c.role", "viewer"), + ]) + .expect("config"); + ConnectAuth::new(Arc::new(ServerAuth::new(&config).expect("auth"))) +} + +fn peer(last: u8) -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 1, 2, last)), 50_000) +} + +/// A request from `peer` with the `authorization` values `headers`. +fn request(peer: Option, headers: &[&str]) -> Request<()> { + let mut r = Request::new(()); + for h in headers { + r.metadata_mut() + .append("authorization", h.parse().expect("ascii")); + } + r.extensions_mut().insert(TcpConnectInfo { + local_addr: None, + remote_addr: peer, + }); + r +} + +fn block_on(f: F) -> F::Output { + futures::executor::block_on(f) +} + +#[test] +fn off_is_the_local_user_without_a_credential() { + let auth = ConnectAuth::off(); + assert!(!auth.is_enabled()); + let caller = block_on(auth.caller(&Request::new(()))).expect("local"); + assert_eq!( + caller.principal.name, + krishiv_common::principal::Principal::local_user().name + ); + assert_eq!(caller.method, Method::None); + assert_eq!(caller.role, None); +} + +#[test] +fn a_token_names_its_principal_and_role() { + let dir = tempfile::tempdir().expect("dir"); + let auth = token_auth(dir.path()); + assert!(auth.is_enabled()); + let caller = block_on(auth.caller(&request(Some(peer(1)), &[&format!("Bearer {TOKEN}")]))) + .expect("authenticated"); + assert_eq!(caller.principal.name, "carol"); + assert_eq!(caller.principal.kind, PrincipalKind::User); + assert_eq!(caller.role, Some(Role::Viewer)); + assert_eq!(caller.method, Method::Token); +} + +#[test] +fn failures_are_spark_unauthenticated_messages() { + let dir = tempfile::tempdir().expect("dir"); + let auth = token_auth(dir.path()); + let status = |headers: &[&str]| { + block_on(auth.caller(&request(Some(peer(2)), headers))).expect_err("refused") + }; + let missing = status(&[]); + assert_eq!(missing.code(), Code::Unauthenticated); + assert_eq!(missing.message(), "No authentication token provided"); + let wrong = status(&["Bearer not-the-token"]); + assert_eq!(wrong.code(), Code::Unauthenticated); + assert_eq!(wrong.message(), "Invalid authentication token"); + // Another scheme, and two `authorization` entries, are malformed: invalid, not missing. + let basic = status(&["Basic dXNlcjpwdw=="]); + assert_eq!(basic.message(), "Invalid authentication token"); + let two = status(&[&format!("Bearer {TOKEN}"), &format!("Bearer {TOKEN}")]); + assert_eq!(two.code(), Code::Unauthenticated); + assert_eq!(two.message(), "Invalid authentication token"); + // The credential never appears in the status. + for s in [&missing, &wrong, &basic, &two] { + assert!(!s.message().contains(TOKEN)); + } +} + +#[test] +fn a_request_without_a_peer_address_is_refused() { + let dir = tempfile::tempdir().expect("dir"); + let auth = token_auth(dir.path()); + let s = block_on(auth.caller(&request(None, &[&format!("Bearer {TOKEN}")]))) + .expect_err("fails closed"); + assert_eq!(s.code(), Code::Unauthenticated); +} + +#[test] +fn a_throttled_peer_is_unavailable() { + let dir = tempfile::tempdir().expect("dir"); + let auth = token_auth(dir.path()); + let mut last = None; + // The default throttle blocks a peer after 20 failures a minute. + for _ in 0..25 { + last = block_on(auth.caller(&request(Some(peer(3)), &["Bearer wrong"]))).err(); + } + let s = last.expect("refused"); + assert_eq!(s.code(), Code::Unavailable); + // Even the right token waits out the block, and another peer is unaffected. + let blocked = block_on(auth.caller(&request(Some(peer(3)), &[&format!("Bearer {TOKEN}")]))); + assert_eq!(blocked.expect_err("blocked").code(), Code::Unavailable); + block_on(auth.caller(&request(Some(peer(4)), &[&format!("Bearer {TOKEN}")]))) + .expect("other peer"); +} + +#[test] +fn the_owner_leaves_out_groups_and_keeps_the_method() { + let dir = tempfile::tempdir().expect("dir"); + let auth = token_auth(dir.path()); + let caller = block_on(auth.caller(&request(Some(peer(5)), &[&format!("Bearer {TOKEN}")]))) + .expect("authenticated"); + let mut grouped = caller.clone(); + grouped.principal.groups = vec!["bi".to_owned()]; + assert_eq!(SessionOwner::of(&caller), SessionOwner::of(&grouped)); + let mut oidc = caller.clone(); + oidc.method = Method::Oidc; + assert_ne!(SessionOwner::of(&caller), SessionOwner::of(&oidc)); +} + +fn command(c: CommandType) -> pb::Command { + pb::Command { + command_type: Some(c), + } +} + +fn udf_usage() -> Check { + Check::Privilege { + securable: Securable::System("udf".to_owned()), + privilege: Privilege::Usage, + class: StatementClass::Session, + } +} + +#[test] +fn every_rpc_has_its_class() { + for rpc in [Rpc::ExecutePlan, Rpc::AnalyzePlan] { + assert_eq!(check_rpc(rpc), Check::Plan, "{rpc:?}"); + } + assert_eq!(check_rpc(Rpc::AddArtifacts), udf_usage()); + for rpc in [ + Rpc::Config, + Rpc::ArtifactStatus, + Rpc::Interrupt, + Rpc::ReattachExecute, + Rpc::ReleaseExecute, + Rpc::ReleaseSession, + Rpc::FetchErrorDetails, + Rpc::CloneSession, + Rpc::GetStatus, + ] { + assert_eq!(check_rpc(rpc), Check::OwnSession, "{rpc:?}"); + } +} + +#[test] +fn every_command_has_its_class() { + assert_eq!(check_command(&pb::Command::default()), Check::Plan); + let cases: Vec<(CommandType, Check)> = vec![ + (CommandType::SqlCommand(Box::default()), Check::Plan), + (CommandType::CheckpointCommand(Box::default()), Check::Plan), + (CommandType::WriteOperation(Box::default()), Check::Write), + (CommandType::WriteOperationV2(Box::default()), Check::Write), + ( + CommandType::MergeIntoTableCommand(Box::default()), + Check::Write, + ), + (CommandType::RegisterFunction(Box::default()), udf_usage()), + ( + CommandType::RegisterTableFunction(Box::default()), + udf_usage(), + ), + (CommandType::RegisterDataSource(Box::default()), udf_usage()), + ( + CommandType::CreateDataframeView(Box::default()), + Check::OwnSession, + ), + ( + CommandType::CreateDataframeView(Box::new(pb::CreateDataFrameViewCommand { + is_global: true, + ..Default::default() + })), + Check::Privilege { + securable: Securable::Namespace(vec![ + "spark_catalog".to_owned(), + "global_temp".to_owned(), + ]), + privilege: Privilege::Create, + class: StatementClass::Ddl, + }, + ), + ( + CommandType::GetResourcesCommand(Box::default()), + Check::OwnSession, + ), + ( + CommandType::RemoveCachedRemoteRelationCommand(Box::default()), + Check::OwnSession, + ), + ( + CommandType::MlCommand(Box::new(pb::MlCommand { + command: Some(MlCommandType::CleanCache(Default::default())), + })), + Check::OwnSession, + ), + ( + CommandType::MlCommand(Box::new(pb::MlCommand { + command: Some(MlCommandType::GetCacheInfo(Default::default())), + })), + Check::Unsupported("ml_command"), + ), + ( + CommandType::MlCommand(Box::default()), + Check::Unsupported("ml_command"), + ), + ( + CommandType::PipelineCommand(Box::default()), + Check::Unsupported("pipeline_command"), + ), + ( + CommandType::ExecuteExternalCommand(Box::default()), + Check::Unsupported("execute_external_command"), + ), + ( + CommandType::CreateResourceProfileCommand(Box::default()), + Check::Unsupported("create_resource_profile_command"), + ), + ( + CommandType::StreamingQueryCommand(Box::default()), + Check::Unsupported("streaming commands"), + ), + ( + CommandType::Extension(Box::default()), + Check::Unsupported("extension"), + ), + ]; + for (c, want) in cases { + assert_eq!(check_command(&command(c.clone())), want, "{c:?}"); + } +} + +#[test] +fn enforce_asks_the_session_policy() { + use krishiv_session::auth::RolePolicy; + let base = krishiv_session::Session::new().expect("session"); + let viewer = base.new_session_as( + krishiv_common::principal::Principal::new("v", PrincipalKind::User), + Arc::new(RolePolicy::new( + Some(Role::Viewer), + Role::Admin, + krishiv_session::PolicyVersion(1), + )), + ); + let e = block_on(super::enforce(&udf_usage(), &viewer)).expect_err("denied"); + assert_eq!(e.error_class().name(), "KRISHIV_INSUFFICIENT_PERMISSIONS"); + for allowed in [ + Check::OwnSession, + Check::Plan, + Check::Write, + Check::Unsupported("x"), + ] { + block_on(super::enforce(&allowed, &viewer)).expect("decided elsewhere"); + } + let admin = base.new_session_as( + krishiv_common::principal::Principal::new("a", PrincipalKind::User), + Arc::new(RolePolicy::new( + Some(Role::Admin), + Role::Admin, + krishiv_session::PolicyVersion(1), + )), + ); + block_on(super::enforce(&udf_usage(), &admin)).expect("admin sends client code"); +} diff --git a/crates/krishiv-connect-server/src/execute.rs b/crates/krishiv-connect-server/src/execute.rs index b8be38de1..740ba46fe 100644 --- a/crates/krishiv-connect-server/src/execute.rs +++ b/crates/krishiv-connect-server/src/execute.rs @@ -21,7 +21,9 @@ //! cancelled operation (ReleaseExecute `release_all`, ReleaseSession) ends //! with `OPERATION_CANCELED`. A client that drops the stream cancels the //! query; in PR-11 the operation is then removed without a tombstone. -//! Commands answer `CONNECT_INVALID_PLAN` (56K00) until P3-T012. +//! Commands answer `CONNECT_INVALID_PLAN` (56K00) until P3-T012, after the +//! check of their ADR 0046 §3b class (`crate::auth::check_command`): a +//! caller without the privilege a command needs is refused with 42501 first. //! //! The core is protocol-neutral where it is cheap: the plan is decoded into //! a `krishiv_plan::Plan`, run as a `DataFrame` and encoded as Arrow IPC; @@ -33,6 +35,7 @@ pub mod schema; use std::io::Read; use std::sync::Arc; +use crate::auth::{Check, check_command}; use arrow_schema::SchemaRef; use futures::StreamExt; use krishiv_common::error::{ @@ -211,7 +214,13 @@ pub async fn execute_plan( } _ => None, }; - let logical = translate_plan(plan, decompressed.as_deref(), &translate)?; + let logical = match translate_plan(plan, decompressed.as_deref(), &translate)? { + Translated::Relation(logical) => logical, + Translated::Command { check, refusal } => { + crate::auth::enforce(&check, session).await?; + return Err(refusal); + } + }; let df = session.from_plan_with(&logical, QueryOptions::new().operation_id(id.as_str()))?; let schema = df.schema().await?; Ok((df, schema)) @@ -250,21 +259,48 @@ pub async fn execute_plan( .boxed()) } +/// What [`translate_plan`] made of a plan. +enum Translated { + /// A ROOT relation as the engine's IR. + Relation(krishiv_plan::Plan), + /// A command (from a compressed operation too): its ADR 0046 §3b class, + /// then the refusal it gets until P3-T012 runs commands. The command + /// itself is not kept: it stays in the scrubbed request. + Command { + /// The command's class. + check: Check, + /// `CONNECT_INVALID_PLAN` naming the command. + refusal: KrishivError, + }, +} + +impl Translated { + fn command(command: &pb::Command) -> Self { + Self::Command { + check: check_command(command), + refusal: command_not_supported(command), + } + } +} + /// The plan as the engine's IR: a ROOT relation translated, a compressed -/// one decompressed first; commands refused until P3-T012. +/// one decompressed first; a command is returned as decoded. fn translate_plan( plan: &pb::Plan, decompressed: Option<&[u8]>, conf: &TranslateConf, -) -> Result { +) -> Result { match &plan.op_type { - Some(OpType::Root(relation)) => translate_relation(relation, conf), - Some(OpType::Command(command)) => Err(command_not_supported(command)), + Some(OpType::Root(relation)) => { + translate_relation(relation, conf).map(Translated::Relation) + } + Some(OpType::Command(command)) => Ok(Translated::command(command)), Some(OpType::CompressedOperation(c)) => { let bytes = decompressed.unwrap_or_default(); match CompressedOpType::try_from(c.op_type) { Ok(CompressedOpType::Relation) => { translate_relation(&*wire::decode_relation(bytes)?, conf) + .map(Translated::Relation) } Ok(CompressedOpType::Command) => { // Decoded through the bounded decoder as the `command` @@ -278,8 +314,8 @@ fn translate_plan( prost::encoding::encode_varint(bytes.len() as u64, &mut wrapped); wrapped.extend_from_slice(bytes); match &wire::decode_plan(&wrapped)?.op_type { - Some(OpType::Command(command)) => Err(command_not_supported(command)), - _ => Err(command_not_supported(&pb::Command::default())), + Some(OpType::Command(command)) => Ok(Translated::command(command)), + _ => Ok(Translated::command(&pb::Command::default())), } } _ => { diff --git a/crates/krishiv-connect-server/src/lib.rs b/crates/krishiv-connect-server/src/lib.rs index 724f4586b..09544486e 100644 --- a/crates/krishiv-connect-server/src/lib.rs +++ b/crates/krishiv-connect-server/src/lib.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 //! The Spark Connect gRPC server (layer L6, design P3-T001): the tonic -//! service over the generated stub, the session manager keyed by principal -//! and session id, the limits applied before admission, and graceful +//! service over the generated stub, the authentication of every RPC, the session manager keyed +//! by the authenticated caller and session id, the limits applied before admission, and graceful //! shutdown. Decoding and translation are `krishiv-connect`'s; the admin //! HTTP server is `krishiv-server`'s; the process around both is //! `krishiv server` (`krishiv-cli`). @@ -10,6 +10,7 @@ // Requests decode only through `krishiv_connect::wire` (the codec here). #![deny(clippy::disallowed_methods)] +pub mod auth; pub mod codec; pub mod execute; pub mod host; @@ -19,6 +20,7 @@ pub mod server; pub mod service; pub mod status; +pub use auth::{Check, ConnectAuth, Rpc, SessionOwner, check_command, check_rpc}; pub use manager::{ManagerCounts, SessionEntry, SessionLimits, SessionManager}; pub use operation::{ExecuteLimits, Operation, Operations}; pub use server::{ConnectServer, ConnectServerConfig}; diff --git a/crates/krishiv-connect-server/src/manager.rs b/crates/krishiv-connect-server/src/manager.rs index 102151fb2..dc974c21f 100644 --- a/crates/krishiv-connect-server/src/manager.rs +++ b/crates/krishiv-connect-server/src/manager.rs @@ -6,11 +6,19 @@ //! admission. //! //! Spark keys by `(UserContext.user_id, session_id)`. Krishiv keys by the -//! authenticated principal instead (ADR 0046 §5, threat model T-A8): the +//! authenticated caller instead (ADR 0046 §5, threat model T-A8): the //! client's `user_id` is recorded, unverified, and never decides anything. -//! A session id another principal holds is refused as unknown -//! (`INVALID_HANDLE.SESSION_NOT_FOUND`), the answer a lookup of an unknown -//! id gets. +//! The key is the caller's [`SessionOwner`] (method, principal kind, tenant +//! and name, without groups). A session id another owner holds is refused as +//! unknown (`INVALID_HANDLE.SESSION_NOT_FOUND`), the answer a lookup of an +//! unknown id gets. +//! +//! A session's policy is fixed when it is built, from the caller's role. A +//! caller whose role differs from the role its live session was built with +//! (a role list changed, an OIDC group claim changed) never gets that +//! session: it is closed, without a tombstone, and the lookup goes on as if +//! the id were free, so a create builds a new session under the new role and +//! a client that saw the old one gets `INVALID_HANDLE.SESSION_CHANGED`. //! //! No lock is held across an `await`, nor while a session is built or //! closed: the map lock guards lookups and inserts only. @@ -28,13 +36,15 @@ use krishiv_common::error::{ InvalidHandleSessionChanged, InvalidHandleSessionClosed, InvalidHandleSessionNotFound, KrishivError, KrishivSessionLimit, }; -use krishiv_common::principal::Principal; +use krishiv_session::auth::Authenticated; use krishiv_session::{QueryId, Session}; +use crate::auth::SessionOwner; use crate::operation::Operations; -/// Builds the session a principal gets for a new session id. -pub type SessionFactory = dyn Fn(&Principal) -> Result + Send + Sync; +/// Builds the session an authenticated caller gets for a new session id: +/// its principal, and the policy of its role. +pub type SessionFactory = dyn Fn(&Authenticated) -> Result + Send + Sync; /// The manager's limits and timers. #[derive(Debug, Clone)] @@ -100,6 +110,7 @@ impl SessionLimits { #[derive(Debug)] pub struct SessionEntry { session: Session, + caller: Authenticated, client_user_id: String, operations: Arc, /// Milliseconds since the manager's epoch at the last lookup. @@ -121,6 +132,12 @@ impl SessionEntry { self.session.id() } + /// The caller the session was built for (its principal and role). + #[must_use] + pub fn caller(&self) -> &Authenticated { + &self.caller + } + /// The `UserContext.user_id` the client sent when it created the /// session: recorded, never trusted. #[must_use] @@ -153,16 +170,16 @@ pub struct ManagerCounts { #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct Key { - principal: Principal, + owner: SessionOwner, session_id: String, } #[derive(Default)] struct Inner { live: HashMap>, - /// The principal holding each live session id. - owners: HashMap, - per_principal: HashMap, + /// The owner holding each live session id. + owners: HashMap, + per_principal: HashMap, closed: Tombstones, /// Set by [`SessionManager::close_all`]: no session is created after it. shut: bool, @@ -240,8 +257,9 @@ impl SessionManager { /// A manager whose sessions are new sessions of `base` (same runtime, /// profile and function registry; Spark's `baseSession.newSession()`). - /// Every principal gets `base`'s principal until authentication (T002) - /// supplies a factory that sets it. + /// Every caller gets `base`'s principal and policy: the server without + /// authentication (loopback only); with it, [`Self::new`] takes a factory + /// that builds each caller's session (`Session::new_session_as`). #[must_use] pub fn from_base(base: Session, limits: SessionLimits) -> Self { Self::new(Box::new(move |_| Ok(base.new_session())), limits) @@ -261,53 +279,87 @@ impl SessionManager { u64::try_from(self.epoch.elapsed().as_millis()).unwrap_or(u64::MAX) } - fn key(principal: &Principal, session_id: &str) -> Key { + fn key(caller: &Authenticated, session_id: &str) -> Key { Key { - principal: principal.clone(), + owner: SessionOwner::of(caller), session_id: session_id.to_owned(), } } - /// The session `session_id` of `principal`, created when missing (the + /// The live entry for `key` if it was built for `caller`'s role, stamped + /// as an access (under the lock, so a sweep cannot release it in + /// between). One built for another role leaves the map without a + /// tombstone and goes to `retired`, to be closed after the lock is + /// released (module docs). + fn live_locked( + &self, + inner: &mut Inner, + key: &Key, + caller: &Authenticated, + retired: &mut Option>, + ) -> Option> { + let entry = inner.live.get(key).map(Arc::clone)?; + if entry.caller.role == caller.role { + entry.last_access.store(self.now_ms(), Ordering::Relaxed); + return Some(entry); + } + *retired = Self::remove_locked(inner, key); + None + } + + /// Closes a session [`Self::live_locked`] retired. + fn close_retired(retired: Option>) { + if let Some(entry) = retired { + tracing::info!( + target: "krishiv::connect", + session = entry.server_side_id(), + "closed a Connect session: its caller's role changed" + ); + entry.close(); + } + } + + /// The session `session_id` of `caller`, created when missing (the /// `ExecutePlan`, `AnalyzePlan` and `Config` RPCs). /// /// # Errors /// `INVALID_HANDLE.FORMAT` for an id that is not a UUID (on create), /// `INVALID_HANDLE.SESSION_CLOSED` for a released id, - /// `INVALID_HANDLE.SESSION_NOT_FOUND` for an id another principal + /// `INVALID_HANDLE.SESSION_NOT_FOUND` for an id another owner /// holds, `INVALID_HANDLE.SESSION_CHANGED` when `observed` is not the /// session's server-side id, `KRISHIV_SESSION_LIMIT` past a session /// cap, and whatever building the session fails with. pub fn get_or_create( &self, - principal: &Principal, + caller: &Authenticated, session_id: &str, observed: Option<&str>, client_user_id: &str, ) -> Result, KrishivError> { - let key = Self::key(principal, session_id); - if let Some(entry) = self.lookup(&key, observed)? { + let key = Self::key(caller, session_id); + if let Some(entry) = self.lookup(&key, caller, observed)? { return Ok(entry); } // Validate before building: Spark's `validateSessionCreate`. QueryId::parse(session_id)?; - if let Some(existing) = self.check_create(&key)? { + if let Some(existing) = self.check_create(&key, caller)? { Self::check_observed(&key, &existing, observed)?; return Ok(existing); } // Built without the lock; a racing create of the same key wins and // this one is closed unused. - let session = (self.factory)(principal)?; + let session = (self.factory)(caller)?; let entry = Arc::new(SessionEntry { session, + caller: caller.clone(), client_user_id: client_user_id.to_owned(), operations: Arc::default(), last_access: AtomicU64::new(self.now_ms()), }); + let mut retired = None; let raced = { let mut inner = self.lock(); - if let Some(existing) = inner.live.get(&key).map(Arc::clone) { - existing.last_access.store(self.now_ms(), Ordering::Relaxed); + if let Some(existing) = self.live_locked(&mut inner, &key, caller, &mut retired) { Ok(Some(existing)) } else { match Self::check_create_locked(&mut inner, &key, &self.limits) { @@ -316,16 +368,14 @@ impl SessionManager { inner.live.insert(key.clone(), Arc::clone(&entry)); inner .owners - .insert(key.session_id.clone(), key.principal.clone()); - *inner - .per_principal - .entry(key.principal.clone()) - .or_insert(0) += 1; + .insert(key.session_id.clone(), key.owner.clone()); + *inner.per_principal.entry(key.owner.clone()).or_insert(0) += 1; Ok(None) } } } }; + Self::close_retired(retired); match raced { Err(e) => { // Built, then refused by a check that changed meanwhile. @@ -344,22 +394,23 @@ impl SessionManager { } } - /// The live session `session_id` of `principal` (every RPC that does not + /// The live session `session_id` of `caller` (every RPC that does not /// create one). /// /// # Errors /// `INVALID_HANDLE.SESSION_CLOSED` for a released id, /// `INVALID_HANDLE.SESSION_NOT_FOUND` for an unknown one (or one another - /// principal holds), `INVALID_HANDLE.SESSION_CHANGED` when `observed` - /// differs from its server-side id. + /// owner holds, or one built for another role of the caller), + /// `INVALID_HANDLE.SESSION_CHANGED` when `observed` differs from its + /// server-side id. pub fn get( &self, - principal: &Principal, + caller: &Authenticated, session_id: &str, observed: Option<&str>, ) -> Result, KrishivError> { - let key = Self::key(principal, session_id); - if let Some(entry) = self.lookup(&key, observed)? { + let key = Self::key(caller, session_id); + if let Some(entry) = self.lookup(&key, caller, observed)? { return Ok(entry); } let closed = self.lock().closed.contains(&key); @@ -381,28 +432,29 @@ impl SessionManager { #[must_use] pub fn get_if_present( &self, - principal: &Principal, + caller: &Authenticated, session_id: &str, ) -> Option> { - let key = Self::key(principal, session_id); - let inner = self.lock(); - let entry = inner.live.get(&key).map(Arc::clone)?; - // Stamped under the lock, so a sweep cannot release it in between. - entry.last_access.store(self.now_ms(), Ordering::Relaxed); - Some(entry) + let key = Self::key(caller, session_id); + let mut retired = None; + let entry = self.live_locked(&mut self.lock(), &key, caller, &mut retired); + Self::close_retired(retired); + entry } - /// Releases session `session_id` of `principal` (`ReleaseSession`): it + /// Releases session `session_id` of `caller` (`ReleaseSession`): it /// leaves the map, its id is tombstoned unless `allow_reconnect`, its /// operations are cancelled and it is closed (its queries cancelled). An unknown id is a no-op. Returns /// the released session's server-side id. pub fn release( &self, - principal: &Principal, + caller: &Authenticated, session_id: &str, allow_reconnect: bool, ) -> Option { - let key = Self::key(principal, session_id); + // Keyed by the owner alone: releasing ends the session whatever role + // it was built with. + let key = Self::key(caller, session_id); let entry = { let mut inner = self.lock(); let entry = Self::remove_locked(&mut inner, &key)?; @@ -505,37 +557,36 @@ impl SessionManager { } } - /// The live entry for `key`, checked against `observed`. + /// The live entry for `key` built for `caller`'s role, checked against + /// `observed`. fn lookup( &self, key: &Key, + caller: &Authenticated, observed: Option<&str>, ) -> Result>, KrishivError> { - let entry = { - let inner = self.lock(); - match inner.live.get(key) { - Some(e) => { - // Stamped under the lock, so a sweep cannot release it - // in between. - e.last_access.store(self.now_ms(), Ordering::Relaxed); - Arc::clone(e) - } - None => { - // Held by another principal: refused as unknown. - if inner - .owners - .get(&key.session_id) - .is_some_and(|p| *p != key.principal) - { - return Err(InvalidHandleSessionNotFound { - handle: key.session_id.clone(), - } - .into()); - } - return Ok(None); + let mut retired = None; + let found = { + let mut inner = self.lock(); + match self.live_locked(&mut inner, key, caller, &mut retired) { + Some(e) => Ok(Some(e)), + // Held by another owner: refused as unknown. + None if inner + .owners + .get(&key.session_id) + .is_some_and(|o| *o != key.owner) => + { + Err(KrishivError::from(InvalidHandleSessionNotFound { + handle: key.session_id.clone(), + })) } + None => Ok(None), } }; + Self::close_retired(retired); + let Some(entry) = found? else { + return Ok(None); + }; Self::check_observed(key, &entry, observed)?; Ok(Some(entry)) } @@ -557,13 +608,21 @@ impl SessionManager { /// The caps, checked before a session is built. A racing create that /// inserted the key since the lookup is returned instead: reusing it /// takes no slot, so the caps must not refuse it. - fn check_create(&self, key: &Key) -> Result>, KrishivError> { - let mut inner = self.lock(); - if let Some(existing) = inner.live.get(key).map(Arc::clone) { - existing.last_access.store(self.now_ms(), Ordering::Relaxed); - return Ok(Some(existing)); - } - Self::check_create_locked(&mut inner, key, &self.limits).map(|()| None) + fn check_create( + &self, + key: &Key, + caller: &Authenticated, + ) -> Result>, KrishivError> { + let mut retired = None; + let checked = { + let mut inner = self.lock(); + match self.live_locked(&mut inner, key, caller, &mut retired) { + Some(existing) => Ok(Some(existing)), + None => Self::check_create_locked(&mut inner, key, &self.limits).map(|()| None), + } + }; + Self::close_retired(retired); + checked } fn check_create_locked( @@ -582,7 +641,7 @@ impl SessionManager { if inner .owners .get(&key.session_id) - .is_some_and(|p| *p != key.principal) + .is_some_and(|o| *o != key.owner) { return Err(InvalidHandleSessionNotFound { handle: key.session_id.clone(), @@ -596,11 +655,7 @@ impl SessionManager { } .into()); } - if inner - .per_principal - .get(&key.principal) - .copied() - .unwrap_or(0) + if inner.per_principal.get(&key.owner).copied().unwrap_or(0) >= limits.max_sessions_per_principal { return Err(KrishivSessionLimit { @@ -615,10 +670,10 @@ impl SessionManager { fn remove_locked(inner: &mut Inner, key: &Key) -> Option> { let entry = inner.live.remove(key)?; inner.owners.remove(&key.session_id); - if let Some(n) = inner.per_principal.get_mut(&key.principal) { + if let Some(n) = inner.per_principal.get_mut(&key.owner) { *n = n.saturating_sub(1); if *n == 0 { - inner.per_principal.remove(&key.principal); + inner.per_principal.remove(&key.owner); } } Some(entry) diff --git a/crates/krishiv-connect-server/src/manager/tests.rs b/crates/krishiv-connect-server/src/manager/tests.rs index 4cea11acb..c78d31216 100644 --- a/crates/krishiv-connect-server/src/manager/tests.rs +++ b/crates/krishiv-connect-server/src/manager/tests.rs @@ -4,18 +4,27 @@ use std::time::Duration; use krishiv_common::principal::{Principal, PrincipalKind}; use krishiv_session::Session; +use krishiv_session::auth::{Authenticated, Method, Role}; use super::{SessionLimits, SessionManager}; const ID: &str = "8f0e4c55-5ad0-4d1e-9a3c-3a1b0b2c7d10"; const ID2: &str = "1d6a3c1e-77b2-4c55-8d0f-0a6c2e9b4f21"; -fn alice() -> Principal { - Principal::new("alice", PrincipalKind::User) +fn user(name: &str) -> Authenticated { + Authenticated::new( + Principal::new(name, PrincipalKind::User), + Some(Role::Operator), + Method::Token, + ) +} + +fn alice() -> Authenticated { + user("alice") } -fn bob() -> Principal { - Principal::new("bob", PrincipalKind::User) +fn bob() -> Authenticated { + user("bob") } fn manager(limits: SessionLimits) -> SessionManager { @@ -167,7 +176,7 @@ fn session_caps_refuse_before_building() { ); m.get_or_create(&bob(), ID2, None, "u") .expect("bob's first"); - let carol = Principal::new("carol", PrincipalKind::User); + let carol = user("carol"); let e = m .get_or_create(&carol, "2b1c6e0a-0f3d-4f7e-9a51-7c6d5e4f3a21", None, "u") .unwrap_err(); @@ -359,3 +368,114 @@ fn a_session_with_a_live_operation_is_busy_and_never_swept() { assert_eq!(m.busy(), 0); assert_eq!(m.sweep(), 1); } + +fn with_groups(mut caller: Authenticated, groups: &[&str]) -> Authenticated { + caller.principal.groups = groups.iter().map(|g| (*g).to_owned()).collect(); + caller +} + +fn with_role(caller: &Authenticated, role: Option) -> Authenticated { + Authenticated::new(caller.principal.clone(), role, caller.method) +} + +#[test] +fn sessions_are_bound_without_groups() { + // An OIDC user whose group claim changes between two tokens keeps its session (design + // P3-T002 §7): groups are not part of the owner. + let m = manager(SessionLimits::default()); + let first = with_groups(alice(), &["etl"]); + let a = m.get_or_create(&first, ID, None, "u").expect("create"); + let b = m + .get( + &with_groups(alice(), &["etl", "bi"]), + ID, + Some(a.server_side_id()), + ) + .expect("same owner, other groups"); + assert!(Arc::ptr_eq(&a, &b)); + assert_eq!(m.counts().live, 1); +} + +#[test] +fn the_method_separates_owners_with_the_same_name() { + // A static token and an OIDC subject both named `alice` are two owners: neither reaches + // the other's session. + let m = manager(SessionLimits::default()); + let token = alice(); + let oidc = Authenticated::new(token.principal.clone(), token.role, Method::Oidc); + m.get_or_create(&token, ID, None, "u").expect("create"); + let e = m.get(&oidc, ID, None).unwrap_err(); + assert_eq!(class(&e), "INVALID_HANDLE.SESSION_NOT_FOUND"); + let e = m.get_or_create(&oidc, ID, None, "u").unwrap_err(); + assert_eq!(class(&e), "INVALID_HANDLE.SESSION_NOT_FOUND"); + assert_eq!(m.counts().live, 1); +} + +#[test] +fn a_changed_role_replaces_the_session() { + // The policy is fixed when a session is built: a caller whose role changed never gets the + // old session. A create builds a new one; a client that saw the old one is told it changed. + let m = manager(SessionLimits::default()); + let operator = alice(); + let old = m.get_or_create(&operator, ID, None, "u").expect("create"); + let viewer = with_role(&operator, Some(Role::Viewer)); + let e = m + .get_or_create(&viewer, ID, Some(old.server_side_id()), "u") + .unwrap_err(); + assert_eq!(class(&e), "INVALID_HANDLE.SESSION_CHANGED"); + assert!(old.session().is_closed(), "the old session is closed"); + let new = m.get(&viewer, ID, None).expect("the new session"); + assert_ne!(new.server_side_id(), old.server_side_id()); + assert_eq!(new.caller().role, Some(Role::Viewer)); + assert_eq!(m.counts().live, 1); + assert_eq!( + m.counts().tombstones, + 0, + "a replaced session leaves no tombstone" + ); +} + +#[test] +fn a_lookup_with_a_changed_role_finds_nothing() { + let m = manager(SessionLimits::default()); + let old = m.get_or_create(&alice(), ID, None, "u").expect("create"); + let e = m.get(&with_role(&alice(), None), ID, None).unwrap_err(); + assert_eq!(class(&e), "INVALID_HANDLE.SESSION_NOT_FOUND"); + assert!(old.session().is_closed()); + assert_eq!(m.counts().live, 0); + assert!(m.get_if_present(&alice(), ID).is_none()); +} + +#[test] +fn release_ends_the_session_whatever_its_role() { + let m = manager(SessionLimits::default()); + let s = m.get_or_create(&alice(), ID, None, "u").expect("create"); + let released = m.release(&with_role(&alice(), Some(Role::Admin)), ID, false); + assert_eq!(released.as_deref(), Some(s.server_side_id())); + assert!(s.session().is_closed()); + assert_eq!(m.counts().live, 0); +} + +#[test] +fn the_factory_gets_the_caller() { + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + let base = Session::new().expect("session"); + let log = Arc::clone(&seen); + let m = SessionManager::new( + Box::new(move |caller: &Authenticated| { + log.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push((caller.principal.name.clone(), caller.role)); + Ok(base.new_session()) + }), + SessionLimits::default(), + ); + let s = m.get_or_create(&bob(), ID, None, "u").expect("create"); + assert_eq!(s.caller(), &bob()); + assert_eq!( + *seen + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + vec![("bob".to_owned(), Some(Role::Operator))] + ); +} diff --git a/crates/krishiv-connect-server/src/server.rs b/crates/krishiv-connect-server/src/server.rs index 5fad388d1..2de63f621 100644 --- a/crates/krishiv-connect-server/src/server.rs +++ b/crates/krishiv-connect-server/src/server.rs @@ -31,6 +31,7 @@ use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use tonic::transport::Server; use tonic::transport::server::{Connected, TcpConnectInfo}; +use crate::auth::ConnectAuth; use crate::manager::SessionManager; use crate::operation::ExecuteLimits; use crate::service::{ConnectService, SparkConnectServiceServer}; @@ -166,19 +167,29 @@ pub struct ConnectServer { manager: Arc, draining: Arc, config: ConnectServerConfig, + auth: Arc, } impl ConnectServer { - /// A server over `manager`. + /// A server over `manager`, with authentication off (loopback only: + /// `krishiv server` refuses another address without it). #[must_use] pub fn new(manager: Arc, config: ConnectServerConfig) -> Self { Self { manager, draining: Arc::new(AtomicBool::new(false)), config, + auth: Arc::new(ConnectAuth::off()), } } + /// This server authenticating every RPC with `auth` (P3-T002). + #[must_use] + pub fn with_auth(mut self, auth: ConnectAuth) -> Self { + self.auth = Arc::new(auth); + self + } + /// The flag that is set once shutdown starts (`/readyz` answers 503 /// from then on). #[must_use] @@ -210,8 +221,10 @@ impl ConnectServer { manager, draining, config, + auth, } = self; let service = ConnectService::new(Arc::clone(&manager), Arc::clone(&draining)) + .with_auth(auth) .with_limits(config.execute.clone()); let service = SparkConnectServiceServer::from_arc(Arc::new(service)) .max_decoding_message_size(config.max_inbound_message_size) diff --git a/crates/krishiv-connect-server/src/service.rs b/crates/krishiv-connect-server/src/service.rs index b21f62b67..7b5345304 100644 --- a/crates/krishiv-connect-server/src/service.rs +++ b/crates/krishiv-connect-server/src/service.rs @@ -4,7 +4,8 @@ //! implementation of it, [`ConnectService`]. //! //! T001 implements the session RPCs (`ReleaseSession`) and what every RPC -//! shares (the principal, the draining refusal). T011a (PR-11a) adds +//! shares (the draining refusal); T002 authenticates every RPC, the +//! unimplemented ones too (`crate::auth`). T011a (PR-11a) adds //! `ExecutePlan` (`crate::execute`) and the minimal `ReattachExecute` and //! `ReleaseExecute` of design P3-T011a §3.4 (no replay buffer: a live //! operation cannot be reattached yet). `AnalyzePlan`, `Config`, `Interrupt` @@ -18,13 +19,14 @@ use krishiv_common::error::{ InvalidCursorNotReattachable, InvalidCursorPositionNotFound, InvalidHandleOperationNotFound, KrishivError, }; -use krishiv_common::principal::Principal; use krishiv_connect::proto::v4_3 as pb; use krishiv_connect::proto::v4_3::release_execute_request::Release; use krishiv_connect::wire::Decoded; +use krishiv_session::auth::Authenticated; use tonic::codegen::BoxStream; use tonic::{Request, Response, Status, Streaming}; +use crate::auth::ConnectAuth; use crate::codec::WireCodec; use crate::execute; use crate::manager::SessionManager; @@ -43,39 +45,35 @@ mod generated { pub use generated::{SparkConnectService, SparkConnectServiceServer}; -/// The principal a request runs as: set by the authentication layer (T002) -/// as a request extension; until then the server's local user, on loopback -/// only (design P3-T001 §3.6). -fn principal_of(request: &Request, default: &Principal) -> Principal { - request - .extensions() - .get::() - .cloned() - .unwrap_or_else(|| default.clone()) -} - /// Krishiv's Spark Connect service over a [`SessionManager`]. #[derive(Debug)] pub struct ConnectService { manager: Arc, draining: Arc, - local: Principal, + auth: Arc, limits: ExecuteLimits, } impl ConnectService { - /// A service over `manager`; new RPCs are refused with `UNAVAILABLE` - /// once `draining` is set (shutdown). + /// A service over `manager`, with authentication off; new RPCs are + /// refused with `UNAVAILABLE` once `draining` is set (shutdown). #[must_use] pub fn new(manager: Arc, draining: Arc) -> Self { Self { manager, draining, - local: Principal::local_user(), + auth: Arc::new(ConnectAuth::off()), limits: ExecuteLimits::default(), } } + /// This service authenticating every RPC with `auth`. + #[must_use] + pub fn with_auth(mut self, auth: Arc) -> Self { + self.auth = auth; + self + } + /// This service with the `ExecutePlan` limits `limits`. #[must_use] pub fn with_limits(mut self, limits: ExecuteLimits) -> Self { @@ -100,10 +98,19 @@ impl ConnectService { } } - fn unimplemented(&self, rpc: &str) -> Status { - match self.admit() { + /// Admits and authenticates `request`: the caller every RPC runs as. + async fn caller(&self, request: &Request) -> Result { + self.admit()?; + self.auth.caller(request).await + } + + /// `UNIMPLEMENTED`, once `request` is admitted and authenticated: an + /// unauthenticated client learns nothing about what the server + /// implements. + async fn unimplemented(&self, request: &Request, rpc: &str) -> Status { + match self.caller(request).await { Err(s) => s, - Ok(()) => Status::unimplemented(format!("{rpc} is not supported by this server yet")), + Ok(_) => Status::unimplemented(format!("{rpc} is not supported by this server yet")), } } } @@ -119,8 +126,7 @@ impl SparkConnectService for ConnectService { &self, request: Request>, ) -> Result, Status> { - self.admit()?; - let principal = principal_of(&request, &self.local); + let caller = self.caller(&request).await?; let request = request.into_inner(); let user = request .user_context @@ -129,7 +135,7 @@ impl SparkConnectService for ConnectService { let entry = self .manager .get_or_create( - &principal, + &caller, &request.session_id, request.client_observed_server_side_session_id.as_deref(), user, @@ -143,37 +149,37 @@ impl SparkConnectService for ConnectService { async fn analyze_plan( &self, - _request: Request>, + request: Request>, ) -> Result, Status> { - Err(self.unimplemented("AnalyzePlan")) + Err(self.unimplemented(&request, "AnalyzePlan").await) } async fn config( &self, - _request: Request>, + request: Request>, ) -> Result, Status> { - Err(self.unimplemented("Config")) + Err(self.unimplemented(&request, "Config").await) } async fn add_artifacts( &self, - _request: Request>>, + request: Request>>, ) -> Result, Status> { - Err(self.unimplemented("AddArtifacts")) + Err(self.unimplemented(&request, "AddArtifacts").await) } async fn artifact_status( &self, - _request: Request>, + request: Request>, ) -> Result, Status> { - Err(self.unimplemented("ArtifactStatus")) + Err(self.unimplemented(&request, "ArtifactStatus").await) } async fn interrupt( &self, - _request: Request>, + request: Request>, ) -> Result, Status> { - Err(self.unimplemented("Interrupt")) + Err(self.unimplemented(&request, "Interrupt").await) } /// Spark's `SparkConnectReattachExecuteHandler`, minimal (design @@ -187,13 +193,12 @@ impl SparkConnectService for ConnectService { &self, request: Request>, ) -> Result, Status> { - self.admit()?; - let principal = principal_of(&request, &self.local); + let caller = self.caller(&request).await?; let request = request.into_inner(); let entry = self .manager .get( - &principal, + &caller, &request.session_id, request.client_observed_server_side_session_id.as_deref(), ) @@ -219,12 +224,11 @@ impl SparkConnectService for ConnectService { &self, request: Request>, ) -> Result, Status> { - self.admit()?; - let principal = principal_of(&request, &self.local); + let caller = self.caller(&request).await?; let request = request.into_inner(); let entry = self .manager - .get(&principal, &request.session_id, None) + .get(&caller, &request.session_id, None) .map_err(|e| status_of(&e))?; let mut response = pb::ReleaseExecuteResponse { session_id: request.session_id.clone(), @@ -273,12 +277,11 @@ impl SparkConnectService for ConnectService { &self, request: Request>, ) -> Result, Status> { - self.admit()?; - let principal = principal_of(&request, &self.local); + let caller = self.caller(&request).await?; let request = request.into_inner(); let server_side = self.manager - .release(&principal, &request.session_id, request.allow_reconnect); + .release(&caller, &request.session_id, request.allow_reconnect); Ok(Response::new(pb::ReleaseSessionResponse { session_id: request.session_id.clone(), server_side_session_id: server_side.unwrap_or_default(), @@ -287,22 +290,22 @@ impl SparkConnectService for ConnectService { async fn fetch_error_details( &self, - _request: Request>, + request: Request>, ) -> Result, Status> { - Err(self.unimplemented("FetchErrorDetails")) + Err(self.unimplemented(&request, "FetchErrorDetails").await) } async fn clone_session( &self, - _request: Request>, + request: Request>, ) -> Result, Status> { - Err(self.unimplemented("CloneSession")) + Err(self.unimplemented(&request, "CloneSession").await) } async fn get_status( &self, - _request: Request>, + request: Request>, ) -> Result, Status> { - Err(self.unimplemented("GetStatus")) + Err(self.unimplemented(&request, "GetStatus").await) } } diff --git a/crates/krishiv-connect-server/tests/auth.rs b/crates/krishiv-connect-server/tests/auth.rs new file mode 100644 index 000000000..1c2e15864 --- /dev/null +++ b/crates/krishiv-connect-server/tests/auth.rs @@ -0,0 +1,348 @@ +// SPDX-License-Identifier: Apache-2.0 +//! The authenticated Connect listener end to end over a raw tonic client (P3-T002, design +//! §3.1, §3.6 and §4; ADR 0046 §3b): every RPC authenticates, sessions are the caller's, and a +//! command's class is checked against the caller's role before it is refused as unsupported. +#![cfg(unix)] +#![expect( + clippy::expect_used, + reason = "integration-test helpers are outside #[test] fns" +)] + +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::Arc; + +use krishiv_connect::proto::v4_3 as pb; +use krishiv_connect::proto::v4_3::command::CommandType; +use krishiv_connect::proto::v4_3::plan::OpType; +use krishiv_connect::proto::v4_3::relation::RelType; +use krishiv_connect_server::{ + ConnectAuth, ConnectServer, ConnectServerConfig, SessionLimits, SessionManager, +}; +use krishiv_session::auth::{AuthConfig, Authenticated, Role, RolePolicy, ServerAuth}; +use krishiv_session::{PolicyVersion, Session}; +use prost::Message; +use tokio::net::TcpListener; +use tokio::sync::oneshot; +use tonic::Code; +use tonic::transport::Channel; + +const SESSION: &str = "8f0e4c55-5ad0-4d1e-9a3c-3a1b0b2c7d10"; +const VIEWER: &str = "viewer-token-0123456789abcdefghijklmnopqrstuv"; +const ADMIN: &str = "admin-token-0123456789abcdefghijklmnopqrstuvw"; + +struct Running { + manager: Arc, + channel: Channel, + _stop: oneshot::Sender<()>, + _dir: tempfile::TempDir, +} + +fn secret(dir: &Path, name: &str, token: &str) -> String { + use std::os::unix::fs::PermissionsExt; + let path = dir.join(name); + std::fs::write(&path, token).expect("write secret"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("chmod"); + format!("file:{}", path.display()) +} + +/// A server with two tokens, `carol` (viewer) and `dana` (admin), whose sessions are built as +/// `krishiv server` builds them: the caller's principal under its role's policy. +async fn start() -> Running { + let dir = tempfile::tempdir().expect("tempdir"); + let v = secret(dir.path(), "v", VIEWER); + let a = secret(dir.path(), "a", ADMIN); + let config = AuthConfig::from_settings([ + ("krishiv.auth.tokens.v.secret", v.as_str()), + ("krishiv.auth.tokens.v.principal", "carol"), + ("krishiv.auth.tokens.v.role", "viewer"), + ("krishiv.auth.tokens.a.secret", a.as_str()), + ("krishiv.auth.tokens.a.principal", "dana"), + ("krishiv.auth.tokens.a.role", "admin"), + ]) + .expect("auth config"); + let auth = Arc::new(ServerAuth::new(&config).expect("auth")); + let base = Session::new().expect("session"); + let manager = Arc::new(SessionManager::new( + Box::new(move |caller: &Authenticated| { + let policy = RolePolicy::new(caller.role, Role::Admin, PolicyVersion(0)); + Ok(base.new_session_as(caller.principal.clone(), Arc::new(policy))) + }), + SessionLimits::default(), + )); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + let server = ConnectServer::new(Arc::clone(&manager), ConnectServerConfig::default()) + .with_auth(ConnectAuth::new(auth)); + let (stop, stopped) = oneshot::channel::<()>(); + tokio::spawn(server.serve(listener, async { + let _ = stopped.await; + })); + let channel = Channel::from_shared(format!("http://{addr}")) + .expect("uri") + .connect() + .await + .expect("connect"); + Running { + manager, + channel, + _stop: stop, + _dir: dir, + } +} + +fn path(rpc: &str) -> tonic::codegen::http::uri::PathAndQuery { + format!("/spark.connect.SparkConnectService/{rpc}") + .parse() + .expect("path") +} + +fn with_token(request: T, token: Option<&str>) -> tonic::Request { + let mut r = tonic::Request::new(request); + if let Some(t) = token { + r.metadata_mut().insert( + "authorization", + format!("Bearer {t}").parse().expect("ascii"), + ); + } + r +} + +async fn unary( + channel: &Channel, + rpc: &str, + request: Req, + token: Option<&str>, +) -> Result +where + Req: prost::Message + Send + Sync + 'static, + Resp: prost::Message + Default + Send + Sync + 'static, +{ + let mut grpc = tonic::client::Grpc::new(channel.clone()); + grpc.ready().await.expect("ready"); + grpc.unary( + with_token(request, token), + path(rpc), + tonic_prost::ProstCodec::::default(), + ) + .await + .map(tonic::Response::into_inner) +} + +/// Every response of an `ExecutePlan`, or the status it ended with. +async fn execute( + channel: &Channel, + plan: pb::Plan, + token: Option<&str>, +) -> Result, tonic::Status> { + let request = pb::ExecutePlanRequest { + session_id: SESSION.to_owned(), + user_context: Some(pb::UserContext { + // Never identity: the token decides who this is. + user_id: "dana".to_owned(), + ..Default::default() + }), + plan: Some(plan), + ..Default::default() + }; + let mut grpc = tonic::client::Grpc::new(channel.clone()); + grpc.ready().await.expect("ready"); + let mut s = grpc + .server_streaming( + with_token(request, token), + path("ExecutePlan"), + tonic_prost::ProstCodec::::default(), + ) + .await? + .into_inner(); + let mut out = Vec::new(); + while let Some(r) = s.message().await? { + out.push(r); + } + Ok(out) +} + +fn range(end: i64) -> pb::Plan { + pb::Plan { + op_type: Some(OpType::Root(Box::new(pb::Relation { + common: None, + rel_type: Some(RelType::Range(pb::Range { + start: Some(0), + end, + step: 1, + num_partitions: None, + })), + }))), + } +} + +fn command(c: CommandType) -> pb::Plan { + pb::Plan { + op_type: Some(OpType::Command(Box::new(pb::Command { + command_type: Some(c), + }))), + } +} + +#[derive(Clone, PartialEq, prost::Message)] +struct RpcStatus { + #[prost(int32, tag = "1")] + code: i32, + #[prost(string, tag = "2")] + message: String, + #[prost(message, repeated, tag = "3")] + details: Vec, +} + +#[derive(Clone, PartialEq, prost::Message)] +struct ErrorInfo { + #[prost(string, tag = "1")] + reason: String, + #[prost(string, tag = "2")] + domain: String, + #[prost(btree_map = "string, string", tag = "3")] + metadata: BTreeMap, +} + +fn error_class(status: &tonic::Status) -> String { + let rpc = RpcStatus::decode(status.details()).expect("google.rpc.Status"); + ErrorInfo::decode(rpc.details[0].value.as_slice()) + .expect("ErrorInfo") + .metadata + .get("errorClass") + .cloned() + .unwrap_or_default() +} + +fn release() -> pb::ReleaseSessionRequest { + pb::ReleaseSessionRequest { + session_id: SESSION.to_owned(), + ..Default::default() + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn every_rpc_needs_a_credential() { + let run = start().await; + // A session RPC, an implemented streaming RPC and an unimplemented one: all are + // `UNAUTHENTICATED` without a token (Spark's message), so an unauthenticated client learns + // nothing about the server. + let s = unary::<_, pb::ReleaseSessionResponse>(&run.channel, "ReleaseSession", release(), None) + .await + .expect_err("no token"); + assert_eq!(s.code(), Code::Unauthenticated); + assert_eq!(s.message(), "No authentication token provided"); + let s = execute(&run.channel, range(3), None) + .await + .expect_err("no token"); + assert_eq!(s.code(), Code::Unauthenticated); + let s = + unary::<_, pb::ConfigResponse>(&run.channel, "Config", pb::ConfigRequest::default(), None) + .await + .expect_err("no token"); + assert_eq!(s.code(), Code::Unauthenticated); + let s = unary::<_, pb::ConfigResponse>( + &run.channel, + "Config", + pb::ConfigRequest::default(), + Some("not-a-configured-token"), + ) + .await + .expect_err("wrong token"); + assert_eq!(s.code(), Code::Unauthenticated); + assert_eq!(s.message(), "Invalid authentication token"); + assert_eq!(run.manager.counts().live, 0, "nothing was created"); + // With a token, the same RPCs get past authentication. + let s = unary::<_, pb::ConfigResponse>( + &run.channel, + "Config", + pb::ConfigRequest::default(), + Some(VIEWER), + ) + .await + .expect_err("not implemented yet"); + assert_eq!(s.code(), Code::Unimplemented); + unary::<_, pb::ReleaseSessionResponse>(&run.channel, "ReleaseSession", release(), Some(VIEWER)) + .await + .expect("an unknown session is a no-op"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sessions_belong_to_the_authenticated_principal() { + let run = start().await; + let responses = execute(&run.channel, range(3), Some(VIEWER)) + .await + .expect("a viewer runs a plan that reads no table"); + assert!(!responses.is_empty()); + // The session is carol's, whatever `user_id` said; its principal is carol. + let carol = run + .manager + .get_if_present( + &Authenticated::new( + krishiv_common::principal::Principal::new( + "carol", + krishiv_common::principal::PrincipalKind::User, + ), + Some(Role::Viewer), + krishiv_session::auth::Method::Token, + ), + SESSION, + ) + .expect("carol's session"); + assert_eq!(carol.caller().principal.name, "carol"); + assert_eq!(carol.client_user_id(), "dana", "recorded, never trusted"); + // dana's token does not reach carol's session id: it is not found, as an unknown id is. + let s = execute(&run.channel, range(3), Some(ADMIN)) + .await + .expect_err("another principal's session id"); + assert_eq!(error_class(&s), "INVALID_HANDLE.SESSION_NOT_FOUND"); + assert_eq!(run.manager.counts().live, 1); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_command_is_checked_against_the_role_before_it_is_refused() { + let run = start().await; + let register = || command(CommandType::RegisterFunction(Box::default())); + // Client code needs `Usage` on `System("udf")`: admin only until T014b (ADR 0046 N1). + let s = execute(&run.channel, register(), Some(VIEWER)) + .await + .expect_err("a viewer sends no client code"); + assert_eq!(error_class(&s), "KRISHIV_INSUFFICIENT_PERMISSIONS"); + // The admin passes the check and meets the refusal every command gets until P3-T012. + release_as(&run, VIEWER).await; + let s = execute(&run.channel, register(), Some(ADMIN)) + .await + .expect_err("commands are not run yet"); + assert!( + error_class(&s).starts_with("CONNECT_INVALID_PLAN"), + "{}", + error_class(&s) + ); + // A global temporary view is a shared object: the `Ddl` class. + release_as(&run, ADMIN).await; + let global = command(CommandType::CreateDataframeView(Box::new( + pb::CreateDataFrameViewCommand { + name: "v".to_owned(), + is_global: true, + ..Default::default() + }, + ))); + let s = execute(&run.channel, global, Some(VIEWER)) + .await + .expect_err("a viewer creates no shared object"); + assert_eq!(error_class(&s), "KRISHIV_INSUFFICIENT_PERMISSIONS"); +} + +async fn release_as(run: &Running, token: &str) { + unary::<_, pb::ReleaseSessionResponse>( + &run.channel, + "ReleaseSession", + pb::ReleaseSessionRequest { + allow_reconnect: true, + ..release() + }, + Some(token), + ) + .await + .expect("release"); +} diff --git a/crates/krishiv-connect-server/tests/execute.rs b/crates/krishiv-connect-server/tests/execute.rs index 6802ba0dd..60c296e8b 100644 --- a/crates/krishiv-connect-server/tests/execute.rs +++ b/crates/krishiv-connect-server/tests/execute.rs @@ -26,6 +26,7 @@ use krishiv_connect_server::{ ConnectServer, ConnectServerConfig, ExecuteLimits, SessionLimits, SessionManager, }; use krishiv_session::Session; +use krishiv_session::auth::{Authenticated, Method}; use prost::Message; use tokio::net::TcpListener; use tokio::sync::oneshot; @@ -242,7 +243,7 @@ async fn execute_response_order() { // small engine batches are not merged (design P3-T011a §3.2), so the // engine's batch size decides how many come below the limits. run.manager - .get_or_create(&Principal::local_user(), SESSION, None, "u") + .get_or_create(&local_caller(), SESSION, None, "u") .expect("session") .session() .conf() @@ -292,7 +293,7 @@ async fn execute_response_order() { )); let entry = run .manager - .get(&Principal::local_user(), SESSION, None) + .get(&local_caller(), SESSION, None) .expect("session"); let ids: HashSet<&str> = responses.iter().map(|r| r.response_id.as_str()).collect(); assert_eq!(ids.len(), responses.len(), "fresh response ids"); @@ -343,7 +344,7 @@ async fn strings_are_utf8_and_chunks_reassemble() { }); let entry = run .manager - .get_or_create(&Principal::local_user(), SESSION, None, "u") + .get_or_create(&local_caller(), SESSION, None, "u") .expect("session"); entry .session() @@ -386,7 +387,7 @@ async fn compressed_ipc_buffers_follow_the_session_codec() { let run = start().await; let entry = run .manager - .get_or_create(&Principal::local_user(), SESSION, None, "u") + .get_or_create(&local_caller(), SESSION, None, "u") .expect("session"); for (codec, expected) in [ ("zstd", arrow_ipc::CompressionType::ZSTD), @@ -577,7 +578,7 @@ async fn invalid_requests_are_refused_with_sparks_classes() { ); let entry = run .manager - .get(&Principal::local_user(), SESSION, None) + .get(&local_caller(), SESSION, None) .expect("session"); assert!( entry.operations().is_empty(), @@ -677,7 +678,7 @@ async fn live_operations_reattach_release_and_cap() { assert_eq!(error_class(&e), "INVALID_HANDLE.OPERATION_NOT_FOUND"); // Another session never sees the operation (operation_bound_to_session). run.manager - .get_or_create(&Principal::local_user(), OTHER_SESSION, None, "u") + .get_or_create(&local_caller(), OTHER_SESSION, None, "u") .expect("other"); let e = stream(&run.channel, "ReattachExecute", reattach(OTHER_SESSION, OP)) .await @@ -748,7 +749,7 @@ async fn live_operations_reattach_release_and_cap() { // release_all cancels the live operation: its stream ends cancelled. let entry = run .manager - .get(&Principal::local_user(), SESSION, None) + .get(&local_caller(), SESSION, None) .expect("session"); let session = entry.session().clone(); let _: pb::ReleaseExecuteResponse = unary( @@ -846,7 +847,7 @@ async fn a_dropped_stream_cancels_the_query_and_frees_its_memory() { .await; let entry = run .manager - .get_or_create(&Principal::local_user(), SESSION, None, "u") + .get_or_create(&local_caller(), SESSION, None, "u") .expect("session"); let session = entry.session().clone(); session @@ -887,7 +888,7 @@ async fn a_dropped_stream_cancels_a_query_that_has_not_produced_yet() { let run = start().await; let entry = run .manager - .get_or_create(&Principal::local_user(), SESSION, None, "u") + .get_or_create(&local_caller(), SESSION, None, "u") .expect("session"); let session = entry.session().clone(); // An aggregate sends nothing until it has read its whole input. @@ -922,7 +923,7 @@ async fn a_cancelled_operation_does_not_wait_forever_for_a_client_that_reads_not .await; let entry = run .manager - .get_or_create(&Principal::local_user(), SESSION, None, "u") + .get_or_create(&local_caller(), SESSION, None, "u") .expect("session"); entry .session() @@ -979,7 +980,7 @@ async fn large_row_result() { assert_eq!(batch.num_chunks_in_batch, None); } run.manager - .get_or_create(&Principal::local_user(), SESSION, None, "u") + .get_or_create(&local_caller(), SESSION, None, "u") .expect("session") .session() .conf() @@ -1013,3 +1014,8 @@ async fn large_row_result() { .expect("utf8"); assert_eq!(s.value(0).len(), 65_536); } + +/// The caller of every request to a server without authentication. +fn local_caller() -> Authenticated { + Authenticated::new(Principal::local_user(), None, Method::None) +} diff --git a/crates/krishiv-connect-server/tests/server.rs b/crates/krishiv-connect-server/tests/server.rs index e3b4da182..8956892da 100644 --- a/crates/krishiv-connect-server/tests/server.rs +++ b/crates/krishiv-connect-server/tests/server.rs @@ -15,6 +15,7 @@ use krishiv_common::principal::Principal; use krishiv_connect::proto::v4_3 as pb; use krishiv_connect_server::{ConnectServer, ConnectServerConfig, SessionLimits, SessionManager}; use krishiv_session::Session; +use krishiv_session::auth::{Authenticated, Method}; use tokio::net::TcpListener; use tokio::sync::oneshot; use tonic::Code; @@ -95,7 +96,7 @@ fn release(session_id: &str, allow_reconnect: bool) -> pb::ReleaseSessionRequest #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn release_session_frees_state() { let run = start(ConnectServerConfig::default(), SessionLimits::default()).await; - let local = Principal::local_user(); + let local = local_caller(); let entry = run .manager .get_or_create(&local, ID, None, "someone") @@ -230,7 +231,7 @@ async fn shutdown_cancels_a_stuck_query_at_the_timeout_and_closes_every_session( SessionLimits::default(), ) .await; - let local = Principal::local_user(); + let local = local_caller(); let entry = run .manager .get_or_create(&local, ID, None, "u") @@ -269,7 +270,7 @@ async fn shutdown_cancels_a_stuck_query_at_the_timeout_and_closes_every_session( async fn shutdown_with_nothing_running_returns_at_once() { let mut run = start(ConnectServerConfig::default(), SessionLimits::default()).await; run.manager - .get_or_create(&Principal::local_user(), ID, None, "u") + .get_or_create(&local_caller(), ID, None, "u") .expect("create"); run.stop.take().expect("stop").send(()).expect("send"); // Well inside the 30 s grace period: no query to wait for. @@ -296,7 +297,7 @@ async fn the_idle_sweeper_runs_on_the_maintenance_interval() { .await; let entry = run .manager - .get_or_create(&Principal::local_user(), ID, None, "u") + .get_or_create(&local_caller(), ID, None, "u") .expect("create"); let deadline = tokio::time::Instant::now() + Duration::from_secs(10); while run.manager.counts().live > 0 { @@ -316,7 +317,7 @@ async fn a_session_with_a_running_query_is_not_swept() { }, ); let entry = manager - .get_or_create(&Principal::local_user(), ID, None, "u") + .get_or_create(&local_caller(), ID, None, "u") .expect("create"); let session = entry.session().clone(); let df = session @@ -412,7 +413,7 @@ async fn shutdown_then_dropping_the_runtime_leaves_no_spill_directory() { .expect("session"); let manager = Arc::new(SessionManager::from_base(base, SessionLimits::default())); let entry = manager - .get_or_create(&Principal::local_user(), ID, None, "u") + .get_or_create(&local_caller(), ID, None, "u") .expect("create"); // A sort larger than the pool spills, which makes the spill directory. let rows = entry @@ -654,3 +655,8 @@ async fn a_connection_cap_past_the_semaphore_limit_is_clamped() { .expect("release"); assert_eq!(response.session_id, ID); } + +/// The caller of every request to a server without authentication. +fn local_caller() -> Authenticated { + Authenticated::new(Principal::local_user(), None, Method::None) +} diff --git a/crates/krishiv-session/CLAUDE.md b/crates/krishiv-session/CLAUDE.md index 765a731e6..02d57512d 100644 --- a/crates/krishiv-session/CLAUDE.md +++ b/crates/krishiv-session/CLAUDE.md @@ -12,7 +12,7 @@ re-exports it; the Python facade and the Connect and Flight SQL servers (L6) wra Paths are relative to this crate. - `src/lib.rs` public entry and re-exports; `public-api.txt` the reviewed public surface - `src/session.rs` `Session` and the query pipeline entry -- `src/runtime.rs` `EngineRuntime` (design P2-E1): what one process shares — executor, memory root, spill directory, the `IoScheduler` with its file-metadata and block caches and the local store (R3), metrics and format registries, query runner, running-query registry (`reserve`/`attach`/`cancel_where`). Sessions are light children (`SessionBuilder::runtime`; `Session::new_session`, `Session::clone_session`, `Session::close`, `Session::sql_with` in `src/session/lifecycle.rs`; `Session::from_plan_with` beside `from_plan`); a session built alone gets a private runtime, so embedded use is unchanged. Runtime-owned keys (`runtime_owned`: cluster scope, `krishiv.memory.*`, `krishiv.spill.*`, `krishiv.io.*`) on a session builder of a supplied runtime are `CANNOT_MODIFY_STATIC_CONFIG`, as `SET` of one is; `close` and `attach` share the registry lock, so no query registers on a closed session. `krishiv.session.memoryLimit` gives a session its own pool under the root (`TreePool::tenant`). A query's I/O goes through one `IoContext` (`QueryEnv::io`), so `system.queries.bytes_read/written` are its own, not the runtime's totals; `tests/runtime.rs`, `tests/runtime_memory.rs`, `tests/runtime_io.rs` +- `src/runtime.rs` `EngineRuntime` (design P2-E1): what one process shares — executor, memory root, spill directory, the `IoScheduler` with its file-metadata and block caches and the local store (R3), metrics and format registries, query runner, running-query registry (`reserve`/`attach`/`cancel_where`). Sessions are light children (`SessionBuilder::runtime`; `Session::new_session`, `Session::new_session_as` (another principal and policy: a server's per-caller sessions, P3-T002), `Session::clone_session`, `Session::close`, `Session::sql_with` in `src/session/lifecycle.rs`; `Session::from_plan_with` beside `from_plan`); a session built alone gets a private runtime, so embedded use is unchanged. Runtime-owned keys (`runtime_owned`: cluster scope, `krishiv.memory.*`, `krishiv.spill.*`, `krishiv.io.*`) on a session builder of a supplied runtime are `CANNOT_MODIFY_STATIC_CONFIG`, as `SET` of one is; `close` and `attach` share the registry lock, so no query registers on a closed session. `krishiv.session.memoryLimit` gives a session its own pool under the root (`TreePool::tenant`). A query's I/O goes through one `IoContext` (`QueryEnv::io`), so `system.queries.bytes_read/written` are its own, not the runtime's totals; `tests/runtime.rs`, `tests/runtime_memory.rs`, `tests/runtime_io.rs` - `src/cancel.rs` (design P2-E1 §4) `CancelReason` (`user`, `timeout`, `admin`, `quota`, `drain`, `client_gone`) and its error (`OPERATION_CANCELED`, `KRISHIV_QUERY_TIMEOUT`, `KRISHIV_QUERY_KILLED`, all HY008), applied through `QueryContext::cancel_with` (first reason wins; drivers and the stream end with it; `system.queries.state` stays `cancelled`, `cancel_reason` names it), and `Deadlines`, the runtime's one timer task (min-heap of weak queries, `krishiv.query.maxExecutionTime` and `QueryOptions::deadline`, the shorter wins). The runtime's registry entry exists from the query's row on (`EngineRuntime::reserve`/`attach`), so a cancel before the context is kept and applied at attach. APIs: `Session::cancel` (own session), `Session::interrupt_all`, `EngineRuntime::cancel` (any session, not reachable from SQL); `tests/runtime_cancel.rs` - `src/history.rs` the runtime-wide `system.queries` rows (`QueryHistory`: UUIDv7 minting; at 1,000 rows a principal loses its oldest finished row; `krishiv.system.queries.capacity` evicts the oldest finished row of the principal holding the most, and so does going past 64 MiB of rows (`QueryRow::heap_bytes`); running rows are never evicted), the public `QueryId` (parsed as Java's `UUID.fromString(id).toString`, as Spark Connect does) and `QueryOptions` (`Session::sql_with`: a caller operation id held by the statement's handles, `Operations` per session as Spark Connect's `SessionHolder`: `INVALID_HANDLE.FORMAT`, `OPERATION_ALREADY_EXISTS` while held, `OPERATION_ABANDONED` for 30 minutes after its last use); `system.rs` `QueryLog` is a session's view of it (tags, trace, principal filter; `Monitor` asks the policy for `MONITOR` on `system` to show every principal's rows); `tests/runtime_queries.rs` - `src/conf.rs` configuration snapshot taken per query (`query_snapshot`, where an unset session time zone is the local zone; @@ -23,7 +23,7 @@ Paths are relative to this crate. - `src/dataframe.rs` DataFrame transformations (IR only) and async actions; `src/column.rs` and `src/functions.rs` user API - `src/query_fold.rs` the bookkeeping around one running query: `QueryStart` (counters read at start), `QueryFold`/`MetricStream`/`PendingFold` (the one fold of its metrics into the engine series and `system.queries` when the stream ends, fails or is dropped; holds the admission permit), `QueuedRow` (the history row of a query waiting for admission) - `src/admission.rs` the admission seam (E5): `AdmissionController::admit(&QueryRequest) -> Permit` asked once per query in `run_on_query_logged`; default `Unlimited`; a waiting `admit` shows the query `queued` in `system.queries`, a refusal closes the row `failed`, a drop while queued `cancelled`; the permit (slot, optional per-query memory budget) lives in the query's `QueryFold` and releases on drop; `tests/admission.rs` -- `src/access.rs` `StatementInfo` / `Session::statement_info` (class + read securables with columns), write/DDL checks (`authorize_statement`), the `krishiv::audit` denial record, `PathPrefixPolicy` (local roots; object-store prefixes by store and whole key segments, `with_object_prefixes`; `from_config` is the server-mode confinement of ADR 0046 §3a from `krishiv.io.local.allowedRoots` / `krishiv.io.objectStore.allowedPrefixes`, default empty, every role, absolute roots; Azure prefixes name their account; with `allowedRoots` set the runtime's local store refuses listings that follow a link out of the roots, `SettledLocalFileSystem::confined`; `tests/path_confinement.rs`). Server-scoped store options (P3-T067b; `krishiv_io::is_server_scoped_option`: every `fs.*` key but an inline key, secret, token or SAS) need `Manage` on `System("cluster")` per read (`bind_files`, table `WITH`), per write (`authorize_write_path`; a table's own stored options are not asked again), on CREATE TABLE `OPTIONS` / `SET SERDEPROPERTIES`, on SQL `SET`, and per query while the session changed one from its builder config (`ConfStore::store_settings_changed`); `tests/server_egress.rs` +- `src/access.rs` `StatementInfo` / `Session::statement_info` (class + read securables with columns), write/DDL checks (`authorize_statement`), `Session::authorize_privilege` (one privilege on one securable outside a statement, audited: the Connect request classes of ADR 0046 §3b), the `krishiv::audit` denial record, `PathPrefixPolicy` (local roots; object-store prefixes by store and whole key segments, `with_object_prefixes`; `from_config` is the server-mode confinement of ADR 0046 §3a from `krishiv.io.local.allowedRoots` / `krishiv.io.objectStore.allowedPrefixes`, default empty, every role, absolute roots; Azure prefixes name their account; with `allowedRoots` set the runtime's local store refuses listings that follow a link out of the roots, `SettledLocalFileSystem::confined`; `tests/path_confinement.rs`). Server-scoped store options (P3-T067b; `krishiv_io::is_server_scoped_option`: every `fs.*` key but an inline key, secret, token or SAS) need `Manage` on `System("cluster")` per read (`bind_files`, table `WITH`), per write (`authorize_write_path`; a table's own stored options are not asked again), on CREATE TABLE `OPTIONS` / `SET SERDEPROPERTIES`, on SQL `SET`, and per query while the session changed one from its builder config (`ConfStore::store_settings_changed`); `tests/server_egress.rs` - `src/auth/` (P3-T002, design `design/P3-T002-authentication.md`, ADR 0046) server authentication: `Role` (viewer < operator < admin), `Credentials` / `Authenticated` / `AuthFailure` (Spark's two `UNAUTHENTICATED` messages), the `Authenticator` trait and `ServerAuth` (throttle, then static tokens; one `krishiv::auth` warn record and one `(surface, method)` counter per failure, never the credential); `config.rs` `AuthConfig::from_settings` (`krishiv.auth.tokens..{secret,principal,kind,role}`, `spark.connect.authenticate.token` = one admin token named `spark`, `krishiv.auth.failureThrottle.*`; unknown `krishiv.auth.*` keys refused); `secret_ref.rs` `env:`/`file:` references (a file readable by group or others refuses startup); `tokens.rs` SHA-256 digests compared in constant time against every entry (secrets >= 32 bytes, no two tokens share one); `throttle.rs` per-address failure blocks, bounded at 65,536 addresses; `mtls.rs` client certificates (`krishiv.auth.clientCertificates` off/optional/required, `clientCa`, `mtls.principalFrom` san-uri/san-dns/cn: exactly one value of that kind or refused, `mtls.userPrincipals`; roles from `krishiv.auth.roles.{viewer,operator,admin}` lists via `config.rs` `RoleLists`; a bearer token next to a certificate must name the same principal; `required` never falls back to a token); `der.rs` `CertNames::from_der`, the bounded no-recursion DER walk of the leaf's subject CN and DNS/URI SANs (fuzz target `fuzz/fuzz_targets/cert_names.rs` includes it by path); `tls_config.rs` `TlsConfig` (`krishiv.server.tls.{certFile,keyFile,minVersion}`) and `check_listener` (startup refusals: non-loopback without auth, non-loopback tokens without TLS, client certificates without TLS); `tls.rs` (feature `tls`) `ServerTls`, the rustls `ServerConfig` on aws-lc-rs with h2/http1.1 ALPN, WebPKI client verifier, a key file checked like a secret file and `reload()` swapping the certified key (a failed reload keeps the old one); `mtls_tests.rs` with in-memory handshakes against `tests/fixtures/tls/`; `oidc_config.rs` `OidcConfig` (`krishiv.auth.oidc.{issuer,audience,jwksUri,algorithms,principalClaim,roleClaim,clockSkew,jwksRefresh,allowInsecureIssuer}` and `krishiv.auth.oidc.roles.{viewer,operator,admin}`, the role-claim values by role, a namespace separate from the certificate names of `krishiv.auth.roles.*`; refused without feature `oidc`; locations https only unless the test flag, with a host, no userinfo, no fragment); `jose.rs` the pure JWS/JWK set/discovery parsers (16 KiB token cap, canonical unpadded base64url, duplicate JSON members refused, `alg` only RS256/384/512 and ES256/384, `crit` refused, header key members ignored; `check_claims`: `exp`/`nbf` with skew, exact `iss`, `aud`, principal and groups; fuzz target `fuzz/fuzz_targets/jwt_parse.rs` includes it by path); `oidc.rs` (feature `oidc`) signatures on aws-lc-rs (no `none`, no HMAC), `JwksCache` (an unknown `kid` asks for one fetch per minute, the first such request waits for it, others fail at once; a token without `kid` needs exactly one fitting key; a failed fetch or a set with no usable key keeps the keys; no keys at all is `Unavailable`) and the `krishiv-jwks` fetch thread (`HttpKeySource`: blocking reqwest, no redirects, 1 MiB cap, one 10 s deadline per fetch); the role of an OIDC principal is the highest any of its role-claim values has in the OIDC role lists; only an invalid token counts toward the throttle; `jose_tests.rs` (every build), `oidc_tests.rs` with keys generated per run, `oidc_http_tests.rs` against a loopback HTTP server; `roles.rs` `RolePolicy`, the ADR 0046 §3 matrix as an `AccessPolicy` (viewer: `Browse`, own `system.*` rows, `/metrics`; operator: `Select`, `Usage`, `Monitor`, `Operate`; admin: `Modify`/`Create`/`Drop`/`Manage`; client code at `krishiv.auth.udf.minRole`; `Impersonate` never; no role denies all), `tests/roles_matrix.rs`. Metadata statements need `Browse` (`authorize` falls back to `Select` for providers that know only `Select`); `ADMIN`-class statements need `Manage` on `System("cluster")` - `src/partitions.rs` partitioned and bucketed tables: the layout `CREATE TABLE` stores (partition columns last), the partitions the catalog tracks (`ADD PARTITION`, `SET LOCATION` and static `INSERT` values cast to the column types, `DROP PARTITION` of a managed table: log first, then the directories; each partitioned table has a read/write lock (`InMemoryCatalogStore::partition_ddl` / `partition_write`, keyed by location, weak entries): `RENAME`/`DROP PARTITION`, `MSCK REPAIR` and `TRUNCATE` hold it exclusively (across their log commit retries) and re-read the entry under it, an `INSERT` holds it shared from its write to its partition registration; external tables refused, registered after `INSERT`/CTAS), `DESCRIBE ... PARTITION`, `SHOW PARTITIONS`, `ANALYZE ... PARTITION`; `tests/partitioned_tables.rs` - `src/column_stats.rs` `ANALYZE TABLE .. FOR COLUMNS` (`CommandUtils.statExprs`): one aggregate over the table, kept in `CatalogTable::column_stats` (the catalog is in memory, no on-disk format), printed by `DESCRIBE EXTENDED t col`; a write, `TRUNCATE`, `ADD PARTITION`, `DROP PARTITION` or `SET LOCATION` drops them (`updateTableStats`); published only to the entry the aggregate read (`CatalogTable::incarnation`, set by the store at create, kept by `replace_table`), so a table dropped and created again meanwhile gets none, nor one whose data changed after it was read (`CatalogTable::data_version`, moved by `data_changed`, which every stats-clearing change calls); `MSCK REPAIR` keeps them, as Spark 4.2.0 does; `tests/char_varchar_write.rs` diff --git a/crates/krishiv-session/public-api.txt b/crates/krishiv-session/public-api.txt index e5defac53..d6f798e37 100644 --- a/crates/krishiv-session/public-api.txt +++ b/crates/krishiv-session/public-api.txt @@ -728,7 +728,6 @@ impl krishiv_session::DataFrame pub fn krishiv_session::DataFrame::agg(&self, impl core::iter::traits::collect::IntoIterator) -> Self pub fn krishiv_session::DataFrame::alias(&self, &str) -> Self pub fn krishiv_session::DataFrame::cache(&self) -> Self -pub async fn krishiv_session::DataFrame::checkpoint(&self, bool) -> core::result::Result pub fn krishiv_session::DataFrame::col(&self, &str) -> krishiv_session::Column pub async fn krishiv_session::DataFrame::collect(&self) -> core::result::Result, krishiv_common::error::KrishivError> pub async fn krishiv_session::DataFrame::count(&self) -> core::result::Result @@ -751,7 +750,6 @@ pub async fn krishiv_session::DataFrame::is_cached(&self) -> core::result::Resul pub fn krishiv_session::DataFrame::join(&self, &Self, krishiv_session::Column, krishiv_session::JoinType) -> Self pub fn krishiv_session::DataFrame::join_using<'a>(&self, &Self, impl core::iter::traits::collect::IntoIterator, krishiv_session::JoinType) -> Self pub fn krishiv_session::DataFrame::limit(&self, i32) -> Self -pub async fn krishiv_session::DataFrame::local_checkpoint(&self, bool, core::option::Option) -> core::result::Result pub fn krishiv_session::DataFrame::offset(&self, i32) -> Self pub fn krishiv_session::DataFrame::order_by(&self, impl core::iter::traits::collect::IntoIterator>) -> Self pub fn krishiv_session::DataFrame::persist(&self, krishiv_session::StorageLevel) -> Self @@ -778,6 +776,9 @@ pub fn krishiv_session::DataFrame::write_to_table(&self, &str) -> krishiv_sessio pub async fn krishiv_session::DataFrame::write_to_table_with(&self, &str, core::option::Option, alloc::vec::Vec<(alloc::string::String, alloc::string::String)>, alloc::vec::Vec<(alloc::string::String, alloc::string::String)>, alloc::vec::Vec, alloc::vec::Vec, &str, core::option::Option) -> core::result::Result<(), krishiv_common::error::KrishivError> pub async fn krishiv_session::DataFrame::write_to_with(&self, &str, &str, &str, alloc::vec::Vec<(alloc::string::String, alloc::string::String)>, alloc::vec::Vec, core::option::Option<(i32, alloc::vec::Vec)>, core::option::Option>) -> core::result::Result<(), krishiv_common::error::KrishivError> impl krishiv_session::DataFrame +pub async fn krishiv_session::DataFrame::checkpoint(&self, bool) -> core::result::Result +pub async fn krishiv_session::DataFrame::local_checkpoint(&self, bool, core::option::Option) -> core::result::Result +impl krishiv_session::DataFrame pub async fn krishiv_session::DataFrame::collect_with_metrics(&self) -> core::result::Result<(alloc::vec::Vec, krishiv_exec::metrics::QueryMetrics), krishiv_common::error::KrishivError> impl krishiv_session::DataFrame pub async fn krishiv_session::DataFrame::collect_with_profile(&self) -> core::result::Result<(alloc::vec::Vec, krishiv_exec::profile::QueryProfile), krishiv_common::error::KrishivError> @@ -1029,6 +1030,8 @@ impl core::fmt::Debug for krishiv_session::RuntimeConf pub fn krishiv_session::RuntimeConf::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct krishiv_session::Session impl krishiv_session::Session +pub async fn krishiv_session::Session::authorize_privilege(&self, &krishiv_plan::analyzer::catalog::Securable, krishiv_plan::analyzer::catalog::Privilege, krishiv_sql::classify::StatementClass) -> core::result::Result<(), krishiv_common::error::KrishivError> +impl krishiv_session::Session pub fn krishiv_session::Session::builder() -> krishiv_session::SessionBuilder pub fn krishiv_session::Session::cache_reserved(&self) -> usize pub fn krishiv_session::Session::clear_cache(&self) @@ -1057,6 +1060,7 @@ pub fn krishiv_session::Session::id(&self) -> &str pub fn krishiv_session::Session::interrupt_all(&self) -> alloc::vec::Vec pub fn krishiv_session::Session::is_closed(&self) -> bool pub fn krishiv_session::Session::new_session(&self) -> Self +pub fn krishiv_session::Session::new_session_as(&self, krishiv_common::principal::Principal, alloc::sync::Arc) -> Self pub fn krishiv_session::Session::running_queries(&self) -> usize pub fn krishiv_session::Session::runtime(&self) -> &alloc::sync::Arc pub async fn krishiv_session::Session::sql_with(&self, &str, krishiv_session::QueryOptions) -> core::result::Result @@ -1127,7 +1131,6 @@ pub krishiv_session::StorageLevel::use_disk: bool pub krishiv_session::StorageLevel::use_memory: bool pub krishiv_session::StorageLevel::use_off_heap: bool impl krishiv_session::StorageLevel -pub fn krishiv_session::StorageLevel::from_name(&str) -> core::option::Option pub const krishiv_session::StorageLevel::DISK_ONLY: Self pub const krishiv_session::StorageLevel::DISK_ONLY_2: Self pub const krishiv_session::StorageLevel::MEMORY_AND_DISK: Self @@ -1136,6 +1139,8 @@ pub const krishiv_session::StorageLevel::MEMORY_ONLY: Self pub const krishiv_session::StorageLevel::MEMORY_ONLY_2: Self pub const krishiv_session::StorageLevel::NONE: Self pub const krishiv_session::StorageLevel::OFF_HEAP: Self +impl krishiv_session::StorageLevel +pub fn krishiv_session::StorageLevel::from_name(&str) -> core::option::Option impl core::clone::Clone for krishiv_session::StorageLevel pub fn krishiv_session::StorageLevel::clone(&self) -> krishiv_session::StorageLevel impl core::cmp::Eq for krishiv_session::StorageLevel diff --git a/crates/krishiv-session/src/access.rs b/crates/krishiv-session/src/access.rs index 85c290f45..f6b44bb08 100644 --- a/crates/krishiv-session/src/access.rs +++ b/crates/krishiv-session/src/access.rs @@ -297,6 +297,29 @@ impl Session { /// `privilege` on the permanent view `namespace.name`, besides the table-named check made /// before dispatch: the statements that show a view's definition, columns or properties /// need `Browse` on it (ADR 0046 §4), `SHOW TABLE EXTENDED` `Select`. + /// Checks that this session's principal holds `privilege` on `securable` under the + /// session's policy, outside any statement: what a server checks for a request before it + /// runs one (the Connect request classes of ADR 0046 §3b). Fails closed as every check + /// does, and a denial writes one `krishiv::audit` record with `class`. + /// + /// # Errors + /// `KRISHIV_INSUFFICIENT_PERMISSIONS` (42501) when the policy does not allow it. + pub async fn authorize_privilege( + &self, + securable: &Securable, + privilege: Privilege, + class: StatementClass, + ) -> Result<(), KrishivError> { + let r = authorize( + &*self.state.policy, + &self.state.principal, + securable, + privilege, + ) + .await; + audit(self, class, r) + } + pub(crate) async fn authorize_view( &self, namespace: &str, diff --git a/crates/krishiv-session/src/session/lifecycle.rs b/crates/krishiv-session/src/session/lifecycle.rs index 8e8160bd3..7d1188aca 100644 --- a/crates/krishiv-session/src/session/lifecycle.rs +++ b/crates/krishiv-session/src/session/lifecycle.rs @@ -178,6 +178,29 @@ impl Session { Self::on_runtime(runtime, config, parts) } + /// [`Self::new_session`] acting for `principal` under `policy` instead of this session's + /// principal and policy: a server's session for one authenticated caller (P3-T002, ADR + /// 0046), built from a base session that holds the server's runtime and profile. The + /// principal and policy are the new session's for life. + #[must_use] + pub fn new_session_as( + &self, + principal: krishiv_common::principal::Principal, + policy: Arc, + ) -> Self { + let state = &self.state; + let runtime = Arc::clone(&state.runtime); + let parts = SessionParts { + profile: state.profile, + principal: Arc::new(principal), + policy, + functions: state.base_functions.clone(), + admission: Some(Arc::clone(&state.admission)), + }; + let config = Config::clone(&runtime.config); + Self::on_runtime(runtime, config, parts) + } + /// A new session on the same runtime that starts as a copy of this one /// (Spark Connect's `CloneSession`, Spark's `SparkSession.cloneSession`): /// the same principal, access policy and profile, and copies of the From f3629359f92d0b3bb000516201095706c536aea5 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:30:13 +0000 Subject: [PATCH 26/47] connect-server, cli: review fixes for Connect authentication (P3-T002 PR-14) - B1: a caller with no role is refused before any session (PERMISSION_DENIED, KRISHIV_INSUFFICIENT_PERMISSIONS, audited), ADR 0046 section 2. - M1: AuthLayer, a tower layer in front of the router, authenticates on the headers before the body is decoded; the service reads the caller from the extensions and fails closed without it. - M2: checkpoint_command is the SideEffect class, not Plan. - M3: role_sessions (the production factory, PathPrefixPolicy(RolePolicy(role))) moves into connect-server; tests/auth.rs runs it with path confinement. - status_of answers 42501 as PERMISSION_DENIED (ADR 0046 section 8); handlers apply check_rpc and refuse a class they cannot apply yet; docs: SESSION_NOT_FOUND on a non-create RPC after a role change, the doc comment of authorize_view back in place. Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- Cargo.lock | 1 + crates/krishiv-cli/CLAUDE.md | 2 +- crates/krishiv-cli/src/server.rs | 35 +--- crates/krishiv-connect-server/CLAUDE.md | 26 ++- crates/krishiv-connect-server/Cargo.toml | 1 + crates/krishiv-connect-server/src/auth.rs | 162 +++++++++++++--- .../krishiv-connect-server/src/auth/layer.rs | 89 +++++++++ .../krishiv-connect-server/src/auth/tests.rs | 71 ++++--- crates/krishiv-connect-server/src/lib.rs | 4 +- crates/krishiv-connect-server/src/manager.rs | 5 +- crates/krishiv-connect-server/src/server.rs | 6 +- crates/krishiv-connect-server/src/service.rs | 51 ++++-- crates/krishiv-connect-server/src/status.rs | 17 +- .../src/status/tests.rs | 19 ++ crates/krishiv-connect-server/tests/auth.rs | 173 ++++++++++++++++-- crates/krishiv-session/src/access.rs | 6 +- 16 files changed, 523 insertions(+), 145 deletions(-) create mode 100644 crates/krishiv-connect-server/src/auth/layer.rs diff --git a/Cargo.lock b/Cargo.lock index 5fe6662d2..333345cb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2297,6 +2297,7 @@ dependencies = [ "tokio", "tonic", "tonic-prost", + "tower", "tracing", "zstd", ] diff --git a/crates/krishiv-cli/CLAUDE.md b/crates/krishiv-cli/CLAUDE.md index e711d660a..f0e59f215 100644 --- a/crates/krishiv-cli/CLAUDE.md +++ b/crates/krishiv-cli/CLAUDE.md @@ -10,7 +10,7 @@ The `krishiv` binary: sql, server, coordinator, worker, mcp, ui, doctor, migrate Paths are relative to this crate. - `src/main.rs` subcommand dispatch and the allocator/memory hook set-up - `src/sql.rs` the `krishiv sql` subcommand: input (`-e`, `-f`, stdin), output (`-o`), `--conf`, `--table`, `--timing`, `--agent`, `--max-rows`, `--max-seconds` -- `src/server.rs` `krishiv server` (feature `server`; `ui` implies it; design P3-T001): host checks before binding (non-loopback refused until P3-T002, root refused without `--allow-root`, core dumps off unless `krishiv.server.allowCoreDumps`), one tokio runtime for the Spark Connect listener (`krishiv-connect-server`, `--connect-port`, default 15002) and the admin listener (`--port` 8080: `/healthz`, `/readyz`, `/metrics` and the UI with `ui`; with `krishiv.auth.*` tokens configured they are guarded by `SessionGuard` over one `ServerAuth`, else by a random token printed once; `krishiv.auth.*` settings never reach the engine configuration). With authentication on, the same `ServerAuth` authenticates every Connect RPC (`ConnectAuth`) and each caller's session is `Session::new_session_as(principal, PathPrefixPolicy(RolePolicy(role)))` (`caller_sessions`; client code admin-only until T014b, ADR 0046 N1; the path confinement is checked at startup), SIGTERM/SIGINT drain then exit 0 after the engine runtime is dropped, a second signal exits 130; `tests/server.rs` +- `src/server.rs` `krishiv server` (feature `server`; `ui` implies it; design P3-T001): host checks before binding (non-loopback refused until P3-T002, root refused without `--allow-root`, core dumps off unless `krishiv.server.allowCoreDumps`), one tokio runtime for the Spark Connect listener (`krishiv-connect-server`, `--connect-port`, default 15002) and the admin listener (`--port` 8080: `/healthz`, `/readyz`, `/metrics` and the UI with `ui`; with `krishiv.auth.*` tokens configured they are guarded by `SessionGuard` over one `ServerAuth`, else by a random token printed once; `krishiv.auth.*` settings never reach the engine configuration). With authentication on, the same `ServerAuth` authenticates every Connect RPC (`ConnectAuth`) and each caller's session is `Session::new_session_as(principal, PathPrefixPolicy(RolePolicy(role)))` (`krishiv_connect_server::role_sessions`; client code admin-only until T014b, ADR 0046 N1; the path confinement is checked at startup; a caller with no role is refused before any session), SIGTERM/SIGINT drain then exit 0 after the engine runtime is dropped, a second signal exits 130; `tests/server.rs` - `src/check_compat/` (P3-T029) `krishiv check-compat `: `mod.rs` args, file walk, exit codes (0 none, 1 findings, 2 read errors), JSON/text report; `scan.rs` SQL / Python / notebook-cell scanners; `alternatives.json` workarounds for not-yet functions (names only supported functions; a unit test checks), `functions.json` the function matrix (generated from the SQL reference pages by `tests/scripts/gen_compat_matrix.py`); corpus `tests/check_compat_corpus/` (`handwritten/` with a hand-checked `expected.json`, `supported_only/` must give no findings, `generated/` from `tests/scripts/gen_check_compat_corpus.py`); JSON report is `{schema_version, files_scanned, findings, errors}` - `src/repl.rs` the interactive loop (no `-e`/`-f` on a terminal): `.timer`, `.format`, `.complete`, `.history`, history persistence - `src/complete.rs` completion sources (keywords, function registry, session catalog, config registry) diff --git a/crates/krishiv-cli/src/server.rs b/crates/krishiv-cli/src/server.rs index 1d5a7330c..f97b527f0 100644 --- a/crates/krishiv-cli/src/server.rs +++ b/crates/krishiv-cli/src/server.rs @@ -26,11 +26,12 @@ use krishiv_common::config::{ }; use krishiv_connect_server::{ ConnectAuth, ConnectServer, ConnectServerConfig, SessionLimits, SessionManager, host, + role_sessions, }; use krishiv_server::guard::{Guard, TokenGuard}; use krishiv_server::session_guard::SessionGuard; -use krishiv_session::auth::{AuthConfig, Authenticated, Role, RolePolicy, ServerAuth, is_auth_key}; -use krishiv_session::{AccessPolicy, EngineRuntime, PathPrefixPolicy, PolicyVersion, Session}; +use krishiv_session::auth::{AuthConfig, ServerAuth, is_auth_key}; +use krishiv_session::{EngineRuntime, Session}; use tokio::net::TcpListener; use tokio::sync::watch; @@ -151,31 +152,6 @@ fn serve(args: &ServerArgs) -> Result<(), String> { result } -/// The session factory of an authenticated server: each caller's session acts for its -/// principal under `PathPrefixPolicy(RolePolicy(role))` (design P3-T002 §3.6), on `base`'s -/// runtime. Client code needs admin until the T014b sandbox merges (ADR 0046 N1). The path -/// confinement is checked once here, so a bad `krishiv.io.*` root fails at startup. -fn caller_sessions( - base: Session, - config: &Config, -) -> Result, String> { - let confine = { - let config = config.clone(); - move |inner: Arc| PathPrefixPolicy::from_config(&config, inner) - }; - confine(Arc::new(RolePolicy::new( - None, - Role::Admin, - PolicyVersion(0), - ))) - .map_err(|e| e.to_string())?; - Ok(Box::new(move |caller: &Authenticated| { - let role = RolePolicy::new(caller.role, Role::Admin, PolicyVersion(0)); - let policy = confine(Arc::new(role))?; - Ok(base.new_session_as(caller.principal.clone(), Arc::new(policy))) - })) -} - fn flag(config: &Config, entry: &krishiv_common::config::ConfigEntry, default: bool) -> bool { config .get(entry) @@ -202,7 +178,10 @@ async fn run_listeners( let auth = Arc::new(auth); let limits = SessionLimits::from_config(config); let manager = Arc::new(if authenticated { - SessionManager::new(caller_sessions(base, config)?, limits) + SessionManager::new( + role_sessions(base, config).map_err(|e| e.to_string())?, + limits, + ) } else { SessionManager::from_base(base, limits) }); diff --git a/crates/krishiv-connect-server/CLAUDE.md b/crates/krishiv-connect-server/CLAUDE.md index 64d9df72c..fb08daabb 100644 --- a/crates/krishiv-connect-server/CLAUDE.md +++ b/crates/krishiv-connect-server/CLAUDE.md @@ -16,11 +16,14 @@ Paths are relative to this crate. - `src/service.rs` `ConnectService`, Krishiv's implementation: `ExecutePlan`, the minimal `ReattachExecute` / `ReleaseExecute` (design P3-T011a §3.4: no replay buffer until PR-15a), `ReleaseSession`; the other RPCs are `UNIMPLEMENTED` until PR-11b/PR-15; a draining server answers `UNAVAILABLE` ("server shutting down") -- `src/auth.rs` (P3-T002, design `design/P3-T002-authentication.md` §3.1/§3.6, ADR 0046 §3b/§5) `ConnectAuth`: - every RPC (the unimplemented ones too) authenticates through the server's one `Authenticator` (`ServerAuth`, - shared with the admin listener): the `authorization` metadata (two entries or a non-`Bearer` scheme are - malformed) and the peer address; failures `UNAUTHENTICATED` with Spark's messages, throttled or unavailable - `UNAVAILABLE`, no peer address refused; auth off = the local user. `SessionOwner` (method, kind, tenant, name, +- `src/auth.rs` (P3-T002, design `design/P3-T002-authentication.md` §3.1/§3.6, ADR 0046 §2/§3b/§5) `ConnectAuth`; + `src/auth/layer.rs` `AuthLayer`, the tower layer in front of the router: every RPC (the unimplemented ones too) + authenticates from its headers before the body is decoded, through the server's one `Authenticator` + (`ServerAuth`, shared with the admin listener): the `authorization` header (two entries or a non-`Bearer` scheme + are malformed) and the peer address; failures `UNAUTHENTICATED` with Spark's messages, throttled or unavailable + `UNAVAILABLE`, no peer address refused, a caller with no role `PERMISSION_DENIED` (42501, audited) before any + session; the result rides in the request extensions (`ConnectAuth::caller` fails closed without it); auth off = + the local user. `role_sessions` is the production session factory (`PathPrefixPolicy(RolePolicy(role))`). `SessionOwner` (method, kind, tenant, name, no groups) is the session key. `check_rpc` / `check_command`: the §3b class of every RPC and `Command` arm, no wildcard arm (a new proto arm fails to compile); `enforce` asks the session's policy for a `Check::Privilege` (`Session::authorize_privilege`, one `krishiv::audit` record per denial); `tests/auth.rs` @@ -36,7 +39,8 @@ Paths are relative to this crate. `src/execute/schema.rs` the `schema` response (`DataTypeProtoConverter.toConnectProtoType`) - `src/codec.rs` `WireCodec`: requests decode only through `wire::decode_request` (bounded size, count, depth, fields) - `src/status.rs` `status_of`: a `KrishivError` as Spark's gRPC status (`INTERNAL`, `google.rpc.ErrorInfo` with - `classes`, `sqlState`, `errorClass`, `messageParameters`; `ErrorUtils.buildStatusFromThrowable`, v4.3.0); + `classes`, `sqlState`, `errorClass`, `messageParameters`; `ErrorUtils.buildStatusFromThrowable`, v4.3.0; + `KRISHIV_INSUFFICIENT_PERMISSIONS` is `PERMISSION_DENIED`, ADR 0046 §8); `exception_classes` picks the Java class list PySpark's `convert_exception` reads (Spark's own for the Connect classes, else the Python facade's class/SQLSTATE rules); `KRISHIV_SESSION_LIMIT` and `KRISHIV_OPERATION_LIMIT` are `RESOURCE_EXHAUSTED` @@ -46,7 +50,7 @@ Paths are relative to this crate. holding a live operation; `busy()` is what shutdown waits for), `krishiv.connect.maxSessions` / `maxSessionsPerPrincipal` - `src/server.rs` `ConnectServer::serve`: HTTP/2 limits, connection cap, sweeper task, shutdown order -- `tests/server.rs`, `tests/execute.rs` end to end over a raw tonic client +- `tests/server.rs`, `tests/execute.rs`, `tests/auth.rs` end to end over a raw tonic client ## Invariants - **Sessions are keyed by the authenticated caller's `SessionOwner`**, never by `UserContext.user_id` (ADR 0046 @@ -54,9 +58,11 @@ Paths are relative to this crate. as unknown. Groups are not in the key; the method is (a token and an OIDC subject of one name are two owners). - **A session's policy is its caller's role at creation, for life**: a caller whose role differs never gets that session; it is closed without a tombstone and the lookup goes on as for a free id (a create builds a new one, - a client that saw the old id gets `INVALID_HANDLE.SESSION_CHANGED`). `ReleaseSession` ends it whatever the role. -- **Every RPC authenticates before anything else but the drain check**, once per RPC (tokens expire, connections - outlive them). + a client that saw the old id gets `INVALID_HANDLE.SESSION_CHANGED`, an RPC that creates nothing + `SESSION_NOT_FOUND`). `ReleaseSession` ends it whatever the role. +- **Every RPC authenticates before its body is decoded** (`AuthLayer`), once per RPC (tokens expire, connections + outlive them); a class `check_rpc` gives that needs more than the session lookup or the analyzer is refused + until its RPC applies it (fail closed). - **No lock across an `await`**, and none while a session is built or closed: the manager's mutex guards map lookups and inserts only. A racing create of the same key keeps the first and closes the loser. - **Decode only through `wire`** (`#![deny(clippy::disallowed_methods)]`): the codec is the one decode path. diff --git a/crates/krishiv-connect-server/Cargo.toml b/crates/krishiv-connect-server/Cargo.toml index 74a71574c..3fec3c794 100644 --- a/crates/krishiv-connect-server/Cargo.toml +++ b/crates/krishiv-connect-server/Cargo.toml @@ -31,6 +31,7 @@ serde_json = { workspace = true, features = ["std"] } sha2.workspace = true tokio = { workspace = true, features = ["net", "rt", "sync", "time", "macros"] } tonic = { workspace = true, features = ["server", "router", "codegen"] } +tower.workspace = true tracing.workspace = true zstd.workspace = true diff --git a/crates/krishiv-connect-server/src/auth.rs b/crates/krishiv-connect-server/src/auth.rs index b4c3bb7f4..f4b23ec79 100644 --- a/crates/krishiv-connect-server/src/auth.rs +++ b/crates/krishiv-connect-server/src/auth.rs @@ -1,38 +1,61 @@ // SPDX-License-Identifier: Apache-2.0 //! Authentication and request classes of the Spark Connect listener (P3-T002; design -//! `design/P3-T002-authentication.md` §3.1 and §3.6, ADR 0046 §3b and §5). +//! `design/P3-T002-authentication.md` §3.1 and §3.6, ADR 0046 §2, §3b and §5). //! -//! - [`ConnectAuth::caller`] authenticates **every RPC**, not each connection: tokens expire -//! and an HTTP/2 connection outlives them. It hands the bearer token of the `authorization` -//! metadata and the peer address (the failure throttle's key) to the server's one -//! [`Authenticator`]. A failure is `UNAUTHENTICATED` with Spark's messages; a throttled peer -//! or an unreachable OIDC provider is `UNAVAILABLE`, which clients retry. A request without -//! a peer address is refused (fail closed). With authentication off every request is the -//! server's local user, which the loopback-only listener rule keeps to this machine. +//! - [`AuthLayer`] authenticates **every RPC** from its headers alone, in front of the tonic +//! service, so nothing of an unauthenticated request's body is read or decoded. Once per +//! RPC, not per connection: tokens expire and an HTTP/2 connection outlives them. +//! [`ConnectAuth::authenticate`] hands the bearer token of the `authorization` header and +//! the peer address (the failure throttle's key) to the server's one [`Authenticator`]. A +//! failure is `UNAUTHENTICATED` with Spark's messages; a throttled peer or an unreachable +//! OIDC provider is `UNAVAILABLE`, which clients retry; a request without a peer address is +//! refused (fail closed). A caller that authenticates but maps to **no role** is refused +//! before any session exists (`PERMISSION_DENIED`, `KRISHIV_INSUFFICIENT_PERMISSIONS`, ADR +//! 0046 §2), with one `krishiv::audit` record. The result rides in the request extensions; +//! [`ConnectAuth::caller`] reads it there and fails closed when it is missing. With +//! authentication off every request is the server's local user, which the loopback-only +//! listener rule keeps to this machine. //! - [`SessionOwner`] is what a Connect session is bound to: the method, the principal's kind, -//! tenant and name, **without its groups**. An OIDC user whose group claim changes between -//! two tokens keeps its sessions, and a static token and an OIDC subject with the same name -//! never share one (design §7, open question; this is the proposed answer). A session's -//! policy is fixed for its life, so a caller whose **role** differs from the role its -//! session was built with does not get that session: the session manager replaces it -//! (`crate::manager`). +//! tenant and name, **without its groups** (one OIDC issuer per server, so the method stands +//! for it). An OIDC user whose group claim changes between two tokens keeps its sessions, +//! and a static token and an OIDC subject with the same name never share one (design §7, +//! open question; this is the proposed answer). A session's policy is fixed for its life, +//! so a caller whose **role** differs from the role its session was built with does not get +//! that session: the session manager replaces it (`crate::manager`). A session keeps the +//! principal (groups included) of the caller that created it. //! - [`check_rpc`] and [`check_command`] give every RPC and every `Command` arm its ADR 0046 //! §3b class. Neither has a wildcard arm, so a proto update that adds an RPC or a command //! does not compile until the new arm is classified. +//! - [`role_sessions`] builds each caller's session as `krishiv server` does: +//! `PathPrefixPolicy(RolePolicy(role))` (design §3.6). +mod layer; + +use std::net::SocketAddr; use std::sync::Arc; +use krishiv_common::config::Config; use krishiv_common::error::KrishivError; use krishiv_common::principal::{Principal, PrincipalKind}; use krishiv_connect::proto::v4_3 as pb; use krishiv_connect::proto::v4_3::command::CommandType; use krishiv_connect::proto::v4_3::ml_command::Command as MlCommandType; +use krishiv_plan::analyzer::insufficient_permissions; use krishiv_session::auth::{ - AuthFailure, Authenticated, Authenticator, Credentials, Method, Surface, + AuthFailure, Authenticated, Authenticator, Credentials, Method, Role, RolePolicy, Surface, +}; +use krishiv_session::{ + PathPrefixPolicy, PolicyVersion, Privilege, Securable, Session, StatementClass, }; -use krishiv_session::{Privilege, Securable, Session, StatementClass}; +use tonic::codegen::http::HeaderMap; +use tonic::codegen::http::header::AUTHORIZATION; use tonic::{Request, Status}; +pub use layer::{AuthLayer, AuthService}; + +use crate::manager::SessionFactory; +use crate::status::status_of; + /// The `system` securable a principal needs `Usage` on to send code the server runs (ADR /// 0046 §4). const SYSTEM_UDF: &str = "udf"; @@ -90,17 +113,27 @@ impl ConnectAuth { self.authenticator.is_some() } - /// The caller of `request`. + fn local(&self) -> Authenticated { + Authenticated::new(self.local.clone(), None, Method::None) + } + + /// Authenticates one request from its `headers` and `peer` address ([`AuthLayer`] calls + /// it before the body is read). /// /// # Errors /// `UNAUTHENTICATED` for a missing, malformed, wrong or expired credential (and for a /// request without a peer address); `UNAVAILABLE` for a throttled peer or a credential that - /// cannot be checked now. - pub async fn caller(&self, request: &Request) -> Result { + /// cannot be checked now; `PERMISSION_DENIED` (`KRISHIV_INSUFFICIENT_PERMISSIONS`) for a + /// caller with no role. + pub async fn authenticate( + &self, + headers: &HeaderMap, + peer: Option, + ) -> Result { let Some(authenticator) = &self.authenticator else { - return Ok(Authenticated::new(self.local.clone(), None, Method::None)); + return Ok(self.local()); }; - let Some(peer) = request.remote_addr() else { + let Some(peer) = peer else { tracing::warn!( target: "krishiv::auth", surface = Surface::Connect.as_str(), @@ -109,7 +142,7 @@ impl ConnectAuth { return Err(failure_status(AuthFailure::Missing)); }; let mut credentials = Credentials::new(peer, Surface::Connect); - let mut values = request.metadata().get_all("authorization").iter(); + let mut values = headers.get_all(AUTHORIZATION).iter(); match (values.next(), values.next()) { (None, _) => {} // One header, readable as text: its `Bearer` token, if it is one. @@ -120,10 +153,52 @@ impl ConnectAuth { // Two `authorization` entries: which one a proxy or the client meant is unknown. (Some(_), Some(_)) => credentials.malformed = true, } - authenticator + let caller = authenticator .authenticate(&credentials) .await - .map_err(failure_status) + .map_err(failure_status)?; + if caller.role.is_none() { + // ADR 0046 §2: no role, no session. `Browse` is the least any role holds. + let error = insufficient_permissions( + &caller.principal, + &Securable::System("cluster".to_owned()), + Privilege::Browse, + ); + tracing::warn!( + target: "krishiv::audit", + principal = %caller.principal.name, + method = caller.method.as_str(), + surface = Surface::Connect.as_str(), + error_class = error.error_class().name(), + "refused a Connect caller with no role" + ); + return Err(status_of(&error)); + } + Ok(caller) + } + + /// The caller of `request`: what [`AuthLayer`] attached to it, or the local user with + /// authentication off. + /// + /// # Errors + /// `UNAUTHENTICATED` when authentication is on and nothing was attached (the service runs + /// without the layer): fail closed. + pub fn caller(&self, request: &Request) -> Result { + if !self.is_enabled() { + return Ok(self.local()); + } + request + .extensions() + .get::() + .cloned() + .ok_or_else(|| { + tracing::error!( + target: "krishiv::auth", + surface = Surface::Connect.as_str(), + "a Connect request reached the service without authentication" + ); + failure_status(AuthFailure::Missing) + }) } } @@ -137,6 +212,27 @@ fn failure_status(failure: AuthFailure) -> Status { } } +/// The session factory of an authenticated server (design §3.6): each caller's session acts +/// for its principal under `PathPrefixPolicy(RolePolicy(role))` on `base`'s runtime. Client +/// code needs admin until the T014b sandbox merges (ADR 0046 N1). The path confinement of +/// `config` (`krishiv.io.local.allowedRoots`, `krishiv.io.objectStore.allowedPrefixes`) is +/// checked once here, so a bad root fails at startup. +/// +/// # Errors +/// What `PathPrefixPolicy::from_config` refuses (`KRISHIV_INVALID_CONFIG_VALUE`). +pub fn role_sessions(base: Session, config: &Config) -> Result, KrishivError> { + let config = config.clone(); + let policy = move |role: Option| { + let roles = RolePolicy::new(role, Role::Admin, PolicyVersion(0)); + PathPrefixPolicy::from_config(&config, Arc::new(roles)) + }; + policy(None)?; + Ok(Box::new(move |caller: &Authenticated| { + let policy = policy(caller.role)?; + Ok(base.new_session_as(caller.principal.clone(), Arc::new(policy))) + })) +} + /// What a Connect session is bound to (module docs): the session manager's key. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct SessionOwner { @@ -164,7 +260,9 @@ impl SessionOwner { pub enum Rpc { /// `ExecutePlan`; its commands are classified by [`check_command`]. ExecutePlan, - /// `AnalyzePlan`. + /// `AnalyzePlan`: `Browse` on what the plan names for most analyses; its `persist` / + /// `unpersist` subtypes are [`Check::SideEffect`] and `input_files` is `Select` on the + /// paths (ADR 0046 §3b), classified per subtype when PR-11b implements the RPC. AnalyzePlan, /// `Config`. Config, @@ -198,6 +296,11 @@ pub enum Check { /// about when it runs the plan (and, for SQL text, the statement classifier, statement by /// statement). Plan, + /// A non-durable side effect (`checkpoint_command`; `AnalyzePlan` `persist` / `unpersist`): + /// the plan's securables plus the operator floor of the ADR 0046 §3 side-effect row + /// (a viewer is refused even for a plan that reads no table), applied with the SQL + /// `CACHE TABLE` path when P3-T012 / PR-11b run them. + SideEffect, /// The `Write` class: `Modify` on the target, plus `Create` when the save mode makes one, /// plus the source plan's securables, through the session's write path (P3-T012 runs it). Write, @@ -252,7 +355,8 @@ pub fn check_command(command: &pb::Command) -> Check { return Check::Plan; }; match command { - CommandType::SqlCommand(_) | CommandType::CheckpointCommand(_) => Check::Plan, + CommandType::SqlCommand(_) => Check::Plan, + CommandType::CheckpointCommand(_) => Check::SideEffect, CommandType::WriteOperation(_) | CommandType::WriteOperationV2(_) | CommandType::MergeIntoTableCommand(_) => Check::Write, @@ -328,7 +432,11 @@ pub async fn enforce(check: &Check, session: &Session) -> Result<(), KrishivErro .authorize_privilege(securable, *privilege, *class) .await } - Check::OwnSession | Check::Plan | Check::Write | Check::Unsupported(_) => Ok(()), + Check::OwnSession + | Check::Plan + | Check::SideEffect + | Check::Write + | Check::Unsupported(_) => Ok(()), } } diff --git a/crates/krishiv-connect-server/src/auth/layer.rs b/crates/krishiv-connect-server/src/auth/layer.rs new file mode 100644 index 000000000..db60b1faa --- /dev/null +++ b/crates/krishiv-connect-server/src/auth/layer.rs @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 +//! [`AuthLayer`]: the tower layer in front of the Connect service that authenticates each +//! request from its headers and peer address before tonic reads or decodes the body (design +//! P3-T002 §3.1). A refusal is answered here as a gRPC status; an accepted request carries its +//! `Authenticated` caller in the request extensions, where [`super::ConnectAuth::caller`] reads +//! it. + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use tonic::codegen::http::{Request, Response}; +use tonic::transport::server::TcpConnectInfo; +use tower::Service; + +use super::ConnectAuth; + +/// Authenticates every request before the wrapped service sees it. +#[derive(Debug, Clone)] +pub struct AuthLayer { + auth: Arc, +} + +impl AuthLayer { + /// The layer of `auth`. + #[must_use] + pub fn new(auth: Arc) -> Self { + Self { auth } + } +} + +impl tower::Layer for AuthLayer { + type Service = AuthService; + + fn layer(&self, inner: S) -> AuthService { + AuthService { + inner, + auth: Arc::clone(&self.auth), + } + } +} + +/// The service [`AuthLayer`] wraps around the Connect router. +#[derive(Debug, Clone)] +pub struct AuthService { + inner: S, + auth: Arc, +} + +type BoxFuture = Pin + Send + 'static>>; + +impl Service> for AuthService +where + S: Service, Response = Response> + Clone + Send + 'static, + S::Future: Send + 'static, + S::Error: Send + 'static, + ReqBody: Send + 'static, + ResBody: Default + Send + 'static, +{ + type Response = Response; + type Error = S::Error; + type Future = BoxFuture>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, mut request: Request) -> Self::Future { + // The service that was polled ready serves this request; a fresh clone stays behind + // for the next one (tower's usual clone-and-swap). + let clone = self.inner.clone(); + let mut inner = std::mem::replace(&mut self.inner, clone); + let auth = Arc::clone(&self.auth); + Box::pin(async move { + let peer = request + .extensions() + .get::() + .and_then(TcpConnectInfo::remote_addr); + match auth.authenticate(request.headers(), peer).await { + Ok(caller) => { + request.extensions_mut().insert(caller); + inner.call(request).await + } + Err(status) => Ok(status.into_http()), + } + }) + } +} diff --git a/crates/krishiv-connect-server/src/auth/tests.rs b/crates/krishiv-connect-server/src/auth/tests.rs index 47b7183a7..1bc97045f 100644 --- a/crates/krishiv-connect-server/src/auth/tests.rs +++ b/crates/krishiv-connect-server/src/auth/tests.rs @@ -8,9 +8,10 @@ use krishiv_common::principal::PrincipalKind; use krishiv_connect::proto::v4_3 as pb; use krishiv_connect::proto::v4_3::command::CommandType; use krishiv_connect::proto::v4_3::ml_command::Command as MlCommandType; +use krishiv_session::auth::Authenticated; use krishiv_session::auth::{AuthConfig, Method, Role, ServerAuth}; use krishiv_session::{Privilege, Securable, StatementClass}; -use tonic::transport::server::TcpConnectInfo; +use tonic::codegen::http::HeaderMap; use tonic::{Code, Request}; use super::{Check, ConnectAuth, Rpc, SessionOwner, check_command, check_rpc}; @@ -49,18 +50,22 @@ fn peer(last: u8) -> SocketAddr { SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 1, 2, last)), 50_000) } -/// A request from `peer` with the `authorization` values `headers`. -fn request(peer: Option, headers: &[&str]) -> Request<()> { - let mut r = Request::new(()); - for h in headers { - r.metadata_mut() - .append("authorization", h.parse().expect("ascii")); +/// The headers of a request with the `authorization` values `values`. +fn headers(values: &[&str]) -> HeaderMap { + let mut h = HeaderMap::new(); + for v in values { + h.append("authorization", v.parse().expect("ascii")); } - r.extensions_mut().insert(TcpConnectInfo { - local_addr: None, - remote_addr: peer, - }); - r + h +} + +/// Authenticates a request from `peer` with the `authorization` values `values`. +fn authenticate( + auth: &ConnectAuth, + peer: Option, + values: &[&str], +) -> Result { + block_on(auth.authenticate(&headers(values), peer)) } fn block_on(f: F) -> F::Output { @@ -71,7 +76,7 @@ fn block_on(f: F) -> F::Output { fn off_is_the_local_user_without_a_credential() { let auth = ConnectAuth::off(); assert!(!auth.is_enabled()); - let caller = block_on(auth.caller(&Request::new(()))).expect("local"); + let caller = auth.caller(&Request::new(())).expect("local"); assert_eq!( caller.principal.name, krishiv_common::principal::Principal::local_user().name @@ -85,8 +90,8 @@ fn a_token_names_its_principal_and_role() { let dir = tempfile::tempdir().expect("dir"); let auth = token_auth(dir.path()); assert!(auth.is_enabled()); - let caller = block_on(auth.caller(&request(Some(peer(1)), &[&format!("Bearer {TOKEN}")]))) - .expect("authenticated"); + let caller = + authenticate(&auth, Some(peer(1)), &[&format!("Bearer {TOKEN}")]).expect("authenticated"); assert_eq!(caller.principal.name, "carol"); assert_eq!(caller.principal.kind, PrincipalKind::User); assert_eq!(caller.role, Some(Role::Viewer)); @@ -97,9 +102,7 @@ fn a_token_names_its_principal_and_role() { fn failures_are_spark_unauthenticated_messages() { let dir = tempfile::tempdir().expect("dir"); let auth = token_auth(dir.path()); - let status = |headers: &[&str]| { - block_on(auth.caller(&request(Some(peer(2)), headers))).expect_err("refused") - }; + let status = |values: &[&str]| authenticate(&auth, Some(peer(2)), values).expect_err("refused"); let missing = status(&[]); assert_eq!(missing.code(), Code::Unauthenticated); assert_eq!(missing.message(), "No authentication token provided"); @@ -122,8 +125,7 @@ fn failures_are_spark_unauthenticated_messages() { fn a_request_without_a_peer_address_is_refused() { let dir = tempfile::tempdir().expect("dir"); let auth = token_auth(dir.path()); - let s = block_on(auth.caller(&request(None, &[&format!("Bearer {TOKEN}")]))) - .expect_err("fails closed"); + let s = authenticate(&auth, None, &[&format!("Bearer {TOKEN}")]).expect_err("fails closed"); assert_eq!(s.code(), Code::Unauthenticated); } @@ -134,23 +136,22 @@ fn a_throttled_peer_is_unavailable() { let mut last = None; // The default throttle blocks a peer after 20 failures a minute. for _ in 0..25 { - last = block_on(auth.caller(&request(Some(peer(3)), &["Bearer wrong"]))).err(); + last = authenticate(&auth, Some(peer(3)), &["Bearer wrong"]).err(); } let s = last.expect("refused"); assert_eq!(s.code(), Code::Unavailable); // Even the right token waits out the block, and another peer is unaffected. - let blocked = block_on(auth.caller(&request(Some(peer(3)), &[&format!("Bearer {TOKEN}")]))); + let blocked = authenticate(&auth, Some(peer(3)), &[&format!("Bearer {TOKEN}")]); assert_eq!(blocked.expect_err("blocked").code(), Code::Unavailable); - block_on(auth.caller(&request(Some(peer(4)), &[&format!("Bearer {TOKEN}")]))) - .expect("other peer"); + authenticate(&auth, Some(peer(4)), &[&format!("Bearer {TOKEN}")]).expect("other peer"); } #[test] fn the_owner_leaves_out_groups_and_keeps_the_method() { let dir = tempfile::tempdir().expect("dir"); let auth = token_auth(dir.path()); - let caller = block_on(auth.caller(&request(Some(peer(5)), &[&format!("Bearer {TOKEN}")]))) - .expect("authenticated"); + let caller = + authenticate(&auth, Some(peer(5)), &[&format!("Bearer {TOKEN}")]).expect("authenticated"); let mut grouped = caller.clone(); grouped.principal.groups = vec!["bi".to_owned()]; assert_eq!(SessionOwner::of(&caller), SessionOwner::of(&grouped)); @@ -199,7 +200,10 @@ fn every_command_has_its_class() { assert_eq!(check_command(&pb::Command::default()), Check::Plan); let cases: Vec<(CommandType, Check)> = vec![ (CommandType::SqlCommand(Box::default()), Check::Plan), - (CommandType::CheckpointCommand(Box::default()), Check::Plan), + ( + CommandType::CheckpointCommand(Box::default()), + Check::SideEffect, + ), (CommandType::WriteOperation(Box::default()), Check::Write), (CommandType::WriteOperationV2(Box::default()), Check::Write), ( @@ -312,3 +316,16 @@ fn enforce_asks_the_session_policy() { ); block_on(super::enforce(&udf_usage(), &admin)).expect("admin sends client code"); } + +#[test] +fn with_authentication_on_a_request_without_the_layers_caller_is_refused() { + // The service reads the caller the layer attached; without one it fails closed. + let dir = tempfile::tempdir().expect("dir"); + let auth = token_auth(dir.path()); + let s = auth.caller(&Request::new(())).expect_err("no layer ran"); + assert_eq!(s.code(), Code::Unauthenticated); + let mut attached = Request::new(()); + let carol = authenticate(&auth, Some(peer(6)), &[&format!("Bearer {TOKEN}")]).expect("token"); + attached.extensions_mut().insert(carol.clone()); + assert_eq!(auth.caller(&attached).expect("attached"), carol); +} diff --git a/crates/krishiv-connect-server/src/lib.rs b/crates/krishiv-connect-server/src/lib.rs index 09544486e..acf79a448 100644 --- a/crates/krishiv-connect-server/src/lib.rs +++ b/crates/krishiv-connect-server/src/lib.rs @@ -20,7 +20,9 @@ pub mod server; pub mod service; pub mod status; -pub use auth::{Check, ConnectAuth, Rpc, SessionOwner, check_command, check_rpc}; +pub use auth::{ + AuthLayer, Check, ConnectAuth, Rpc, SessionOwner, check_command, check_rpc, role_sessions, +}; pub use manager::{ManagerCounts, SessionEntry, SessionLimits, SessionManager}; pub use operation::{ExecuteLimits, Operation, Operations}; pub use server::{ConnectServer, ConnectServerConfig}; diff --git a/crates/krishiv-connect-server/src/manager.rs b/crates/krishiv-connect-server/src/manager.rs index dc974c21f..7aeba8543 100644 --- a/crates/krishiv-connect-server/src/manager.rs +++ b/crates/krishiv-connect-server/src/manager.rs @@ -18,7 +18,10 @@ //! (a role list changed, an OIDC group claim changed) never gets that //! session: it is closed, without a tombstone, and the lookup goes on as if //! the id were free, so a create builds a new session under the new role and -//! a client that saw the old one gets `INVALID_HANDLE.SESSION_CHANGED`. +//! a client that saw the old one gets `INVALID_HANDLE.SESSION_CHANGED`; an +//! RPC that creates nothing gets `INVALID_HANDLE.SESSION_NOT_FOUND`. Two +//! clients of one owner using one session id under different roles at once +//! replace each other's session on every request (each keeps its own role). //! //! No lock is held across an `await`, nor while a session is built or //! closed: the map lock guards lookups and inserts only. diff --git a/crates/krishiv-connect-server/src/server.rs b/crates/krishiv-connect-server/src/server.rs index 2de63f621..569cdfef8 100644 --- a/crates/krishiv-connect-server/src/server.rs +++ b/crates/krishiv-connect-server/src/server.rs @@ -31,7 +31,7 @@ use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use tonic::transport::Server; use tonic::transport::server::{Connected, TcpConnectInfo}; -use crate::auth::ConnectAuth; +use crate::auth::{AuthLayer, ConnectAuth}; use crate::manager::SessionManager; use crate::operation::ExecuteLimits; use crate::service::{ConnectService, SparkConnectServiceServer}; @@ -224,7 +224,7 @@ impl ConnectServer { auth, } = self; let service = ConnectService::new(Arc::clone(&manager), Arc::clone(&draining)) - .with_auth(auth) + .with_auth(Arc::clone(&auth)) .with_limits(config.execute.clone()); let service = SparkConnectServiceServer::from_arc(Arc::new(service)) .max_decoding_message_size(config.max_inbound_message_size) @@ -241,6 +241,8 @@ impl ConnectServer { .http2_keepalive_interval(keepalive_interval) .http2_keepalive_timeout(keepalive_timeout) .tcp_nodelay(true) + // Authentication runs on the headers, before the body is decoded. + .layer(AuthLayer::new(Arc::clone(&auth))) .add_service(service); // Aborted on drop, so a caller that cancels `serve` leaves no task diff --git a/crates/krishiv-connect-server/src/service.rs b/crates/krishiv-connect-server/src/service.rs index 7b5345304..6c3d33a2d 100644 --- a/crates/krishiv-connect-server/src/service.rs +++ b/crates/krishiv-connect-server/src/service.rs @@ -26,7 +26,7 @@ use krishiv_session::auth::Authenticated; use tonic::codegen::BoxStream; use tonic::{Request, Response, Status, Streaming}; -use crate::auth::ConnectAuth; +use crate::auth::{Check, ConnectAuth, Rpc, check_rpc}; use crate::codec::WireCodec; use crate::execute; use crate::manager::SessionManager; @@ -98,19 +98,32 @@ impl ConnectService { } } - /// Admits and authenticates `request`: the caller every RPC runs as. - async fn caller(&self, request: &Request) -> Result { + /// Admits `request` and returns its caller (authenticated by + /// [`crate::auth::AuthLayer`] before the body was read), after the + /// ADR 0046 §3b class of `rpc`. A class that needs more than the + /// session lookup or the analyzer (a privilege, the write path, a side + /// effect) is refused until the RPC that needs it applies it: fail + /// closed, so a newly implemented RPC cannot skip its check. + fn caller(&self, request: &Request, rpc: Rpc) -> Result { self.admit()?; - self.auth.caller(request).await + let caller = self.auth.caller(request)?; + match check_rpc(rpc) { + Check::OwnSession | Check::Plan => Ok(caller), + Check::Privilege { .. } | Check::Write | Check::SideEffect | Check::Unsupported(_) => { + Err(Status::unimplemented(format!( + "{rpc:?} is not supported by this server yet" + ))) + } + } } /// `UNIMPLEMENTED`, once `request` is admitted and authenticated: an /// unauthenticated client learns nothing about what the server /// implements. - async fn unimplemented(&self, request: &Request, rpc: &str) -> Status { - match self.caller(request).await { + fn unimplemented(&self, request: &Request, rpc: Rpc) -> Status { + match self.caller(request, rpc) { Err(s) => s, - Ok(_) => Status::unimplemented(format!("{rpc} is not supported by this server yet")), + Ok(_) => Status::unimplemented(format!("{rpc:?} is not supported by this server yet")), } } } @@ -126,7 +139,7 @@ impl SparkConnectService for ConnectService { &self, request: Request>, ) -> Result, Status> { - let caller = self.caller(&request).await?; + let caller = self.caller(&request, Rpc::ExecutePlan)?; let request = request.into_inner(); let user = request .user_context @@ -151,35 +164,35 @@ impl SparkConnectService for ConnectService { &self, request: Request>, ) -> Result, Status> { - Err(self.unimplemented(&request, "AnalyzePlan").await) + Err(self.unimplemented(&request, Rpc::AnalyzePlan)) } async fn config( &self, request: Request>, ) -> Result, Status> { - Err(self.unimplemented(&request, "Config").await) + Err(self.unimplemented(&request, Rpc::Config)) } async fn add_artifacts( &self, request: Request>>, ) -> Result, Status> { - Err(self.unimplemented(&request, "AddArtifacts").await) + Err(self.unimplemented(&request, Rpc::AddArtifacts)) } async fn artifact_status( &self, request: Request>, ) -> Result, Status> { - Err(self.unimplemented(&request, "ArtifactStatus").await) + Err(self.unimplemented(&request, Rpc::ArtifactStatus)) } async fn interrupt( &self, request: Request>, ) -> Result, Status> { - Err(self.unimplemented(&request, "Interrupt").await) + Err(self.unimplemented(&request, Rpc::Interrupt)) } /// Spark's `SparkConnectReattachExecuteHandler`, minimal (design @@ -193,7 +206,7 @@ impl SparkConnectService for ConnectService { &self, request: Request>, ) -> Result, Status> { - let caller = self.caller(&request).await?; + let caller = self.caller(&request, Rpc::ReattachExecute)?; let request = request.into_inner(); let entry = self .manager @@ -224,7 +237,7 @@ impl SparkConnectService for ConnectService { &self, request: Request>, ) -> Result, Status> { - let caller = self.caller(&request).await?; + let caller = self.caller(&request, Rpc::ReleaseExecute)?; let request = request.into_inner(); let entry = self .manager @@ -277,7 +290,7 @@ impl SparkConnectService for ConnectService { &self, request: Request>, ) -> Result, Status> { - let caller = self.caller(&request).await?; + let caller = self.caller(&request, Rpc::ReleaseSession)?; let request = request.into_inner(); let server_side = self.manager @@ -292,20 +305,20 @@ impl SparkConnectService for ConnectService { &self, request: Request>, ) -> Result, Status> { - Err(self.unimplemented(&request, "FetchErrorDetails").await) + Err(self.unimplemented(&request, Rpc::FetchErrorDetails)) } async fn clone_session( &self, request: Request>, ) -> Result, Status> { - Err(self.unimplemented(&request, "CloneSession").await) + Err(self.unimplemented(&request, Rpc::CloneSession)) } async fn get_status( &self, request: Request>, ) -> Result, Status> { - Err(self.unimplemented(&request, "GetStatus").await) + Err(self.unimplemented(&request, Rpc::GetStatus)) } } diff --git a/crates/krishiv-connect-server/src/status.rs b/crates/krishiv-connect-server/src/status.rs index 92bb0eb72..c0b1cacc4 100644 --- a/crates/krishiv-connect-server/src/status.rs +++ b/crates/krishiv-connect-server/src/status.rs @@ -195,14 +195,15 @@ pub fn status_of(error: &KrishivError) -> Status { metadata, }; // Spark sends INTERNAL for every SparkThrowable; a session limit is a - // refusal before admission (threat model T-D4), RESOURCE_EXHAUSTED. - let code = if matches!( - class, - ErrorClass::KrishivSessionLimit | ErrorClass::KrishivOperationLimit - ) { - Code::ResourceExhausted - } else { - Code::Internal + // refusal before admission (threat model T-D4), RESOURCE_EXHAUSTED; an + // access denial is PERMISSION_DENIED (ADR 0046 §8), with the same + // ErrorInfo, which PySpark reads whatever the code. + let code = match class { + ErrorClass::KrishivSessionLimit | ErrorClass::KrishivOperationLimit => { + Code::ResourceExhausted + } + ErrorClass::KrishivInsufficientPermissions => Code::PermissionDenied, + _ => Code::Internal, }; let message = abbreviate(&error.to_string(), MAX_MESSAGE); let details = RpcStatus { diff --git a/crates/krishiv-connect-server/src/status/tests.rs b/crates/krishiv-connect-server/src/status/tests.rs index 9466e40e1..fa8879796 100644 --- a/crates/krishiv-connect-server/src/status/tests.rs +++ b/crates/krishiv-connect-server/src/status/tests.rs @@ -167,3 +167,22 @@ fn an_operation_limit_is_resource_exhausted() { .into(); assert_eq!(status_of(&e).code(), Code::ResourceExhausted); } + +#[test] +fn an_access_denial_is_permission_denied_with_its_class() { + let e: KrishivError = krishiv_common::error::KrishivInsufficientPermissions { + principal: "`carol`".to_owned(), + privilege: "USAGE".to_owned(), + securable: "system `udf`".to_owned(), + } + .into(); + let status = status_of(&e); + assert_eq!(status.code(), Code::PermissionDenied); + let (rpc, info) = details(&status); + assert_eq!(rpc.code, Code::PermissionDenied as i32); + assert_eq!( + info.metadata["errorClass"], + "KRISHIV_INSUFFICIENT_PERMISSIONS" + ); + assert_eq!(info.metadata["sqlState"], "42501"); +} diff --git a/crates/krishiv-connect-server/tests/auth.rs b/crates/krishiv-connect-server/tests/auth.rs index 1c2e15864..3e270accd 100644 --- a/crates/krishiv-connect-server/tests/auth.rs +++ b/crates/krishiv-connect-server/tests/auth.rs @@ -12,15 +12,20 @@ use std::collections::BTreeMap; use std::path::Path; use std::sync::Arc; +use krishiv_common::config::Config; +use krishiv_common::principal::{Principal, PrincipalKind}; use krishiv_connect::proto::v4_3 as pb; use krishiv_connect::proto::v4_3::command::CommandType; use krishiv_connect::proto::v4_3::plan::OpType; use krishiv_connect::proto::v4_3::relation::RelType; use krishiv_connect_server::{ - ConnectAuth, ConnectServer, ConnectServerConfig, SessionLimits, SessionManager, + ConnectAuth, ConnectServer, ConnectServerConfig, SessionLimits, SessionManager, role_sessions, +}; +use krishiv_session::BoxFuture; +use krishiv_session::Session; +use krishiv_session::auth::{ + AuthConfig, AuthFailure, Authenticated, Authenticator, Credentials, Method, Role, ServerAuth, }; -use krishiv_session::auth::{AuthConfig, Authenticated, Role, RolePolicy, ServerAuth}; -use krishiv_session::{PolicyVersion, Session}; use prost::Message; use tokio::net::TcpListener; use tokio::sync::oneshot; @@ -30,6 +35,7 @@ use tonic::transport::Channel; const SESSION: &str = "8f0e4c55-5ad0-4d1e-9a3c-3a1b0b2c7d10"; const VIEWER: &str = "viewer-token-0123456789abcdefghijklmnopqrstuv"; const ADMIN: &str = "admin-token-0123456789abcdefghijklmnopqrstuvw"; +const OPERATOR: &str = "operator-token-0123456789abcdefghijklmnopqrstu"; struct Running { manager: Arc, @@ -46,30 +52,46 @@ fn secret(dir: &Path, name: &str, token: &str) -> String { format!("file:{}", path.display()) } -/// A server with two tokens, `carol` (viewer) and `dana` (admin), whose sessions are built as -/// `krishiv server` builds them: the caller's principal under its role's policy. +/// A server with three tokens, `carol` (viewer), `olga` (operator) and `dana` (admin), whose +/// sessions are built by the factory `krishiv server` uses (`role_sessions`), with local paths +/// confined to `/data`. async fn start() -> Running { let dir = tempfile::tempdir().expect("tempdir"); let v = secret(dir.path(), "v", VIEWER); + let o = secret(dir.path(), "o", OPERATOR); let a = secret(dir.path(), "a", ADMIN); let config = AuthConfig::from_settings([ ("krishiv.auth.tokens.v.secret", v.as_str()), ("krishiv.auth.tokens.v.principal", "carol"), ("krishiv.auth.tokens.v.role", "viewer"), + ("krishiv.auth.tokens.o.secret", o.as_str()), + ("krishiv.auth.tokens.o.principal", "olga"), + ("krishiv.auth.tokens.o.role", "operator"), ("krishiv.auth.tokens.a.secret", a.as_str()), ("krishiv.auth.tokens.a.principal", "dana"), ("krishiv.auth.tokens.a.role", "admin"), ]) .expect("auth config"); let auth = Arc::new(ServerAuth::new(&config).expect("auth")); - let base = Session::new().expect("session"); - let manager = Arc::new(SessionManager::new( - Box::new(move |caller: &Authenticated| { - let policy = RolePolicy::new(caller.role, Role::Admin, PolicyVersion(0)); - Ok(base.new_session_as(caller.principal.clone(), Arc::new(policy))) - }), - SessionLimits::default(), - )); + start_with(auth, dir).await +} + +async fn start_with(auth: Arc, dir: tempfile::TempDir) -> Running { + let data = dir.path().join("data"); + std::fs::create_dir(&data).expect("data dir"); + let mut config = Config::new(); + config + .set( + "krishiv.io.local.allowedRoots", + &data + .canonicalize() + .expect("canonical") + .display() + .to_string(), + ) + .expect("allowed roots"); + let factory = role_sessions(Session::new().expect("session"), &config).expect("factory"); + let manager = Arc::new(SessionManager::new(factory, SessionLimits::default())); let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("addr"); let server = ConnectServer::new(Arc::clone(&manager), ConnectServerConfig::default()) @@ -279,12 +301,9 @@ async fn sessions_belong_to_the_authenticated_principal() { .manager .get_if_present( &Authenticated::new( - krishiv_common::principal::Principal::new( - "carol", - krishiv_common::principal::PrincipalKind::User, - ), + Principal::new("carol", PrincipalKind::User), Some(Role::Viewer), - krishiv_session::auth::Method::Token, + Method::Token, ), SESSION, ) @@ -346,3 +365,121 @@ async fn release_as(run: &Running, token: &str) { .await .expect("release"); } + +fn sql(query: &str) -> pb::Plan { + pb::Plan { + op_type: Some(OpType::Root(Box::new(pb::Relation { + common: None, + rel_type: Some(RelType::Sql(pb::Sql { + query: query.to_owned(), + ..Default::default() + })), + }))), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sessions_get_the_role_policy_and_the_path_confinement() { + let run = start().await; + // A viewer reads no rows, even of a file inside the allowed roots. + let s = execute(&run.channel, sql("SELECT * FROM range(1)"), Some(VIEWER)) + .await + .expect("a plan that reads no table needs no privilege"); + assert!(!s.is_empty()); + release_as(&run, VIEWER).await; + // An operator may read rows, but not by a path outside `krishiv.io.local.allowedRoots`. + let outside = run._dir.path().join("outside.parquet"); + let s = execute( + &run.channel, + sql(&format!("SELECT * FROM parquet.`{}`", outside.display())), + Some(OPERATOR), + ) + .await + .expect_err("confined"); + assert_eq!(s.code(), Code::PermissionDenied); + assert_eq!(error_class(&s), "KRISHIV_INSUFFICIENT_PERMISSIONS"); + // Inside the roots the same read gets past the policy (the file does not exist). + let inside = run._dir.path().join("data").join("missing.parquet"); + let s = execute( + &run.channel, + sql(&format!("SELECT * FROM parquet.`{}`", inside.display())), + Some(OPERATOR), + ) + .await + .expect_err("no such file"); + assert_ne!(error_class(&s), "KRISHIV_INSUFFICIENT_PERMISSIONS"); +} + +/// An authenticator whose callers map to no role (an OIDC subject in no role list, a +/// certificate principal in none). +struct NoRole; + +impl Authenticator for NoRole { + fn authenticate<'a>( + &'a self, + _credentials: &'a Credentials<'a>, + ) -> BoxFuture<'a, Result> { + Box::pin(async { + Ok(Authenticated::new( + Principal::new("nobody", PrincipalKind::User), + None, + Method::Oidc, + )) + }) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_caller_with_no_role_gets_no_session() { + let run = start_with(Arc::new(NoRole), tempfile::tempdir().expect("tempdir")).await; + // ADR 0046 §2: refused when the session would be created, before anything is built. + let s = execute(&run.channel, range(3), Some("any")) + .await + .expect_err("no role"); + assert_eq!(s.code(), Code::PermissionDenied); + assert_eq!(error_class(&s), "KRISHIV_INSUFFICIENT_PERMISSIONS"); + let s = unary::<_, pb::ReleaseSessionResponse>( + &run.channel, + "ReleaseSession", + release(), + Some("any"), + ) + .await + .expect_err("no role"); + assert_eq!(s.code(), Code::PermissionDenied); + assert_eq!(run.manager.counts().live, 0); +} + +/// Sends `body` (a message that is no `ExecutePlanRequest`: field 1 as a varint where a +/// string is expected) as an `ExecutePlan`. +async fn undecodable(channel: &Channel, token: Option<&str>) -> tonic::Status { + let body = prost_types::Duration { + seconds: 5, + nanos: 0, + }; + let mut grpc = tonic::client::Grpc::new(channel.clone()); + grpc.ready().await.expect("ready"); + match grpc + .server_streaming( + with_token(body, token), + path("ExecutePlan"), + tonic_prost::ProstCodec::::default(), + ) + .await + { + Ok(mut s) => s.get_mut().message().await.expect_err("refused"), + Err(status) => status, + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn authentication_comes_before_the_body_is_decoded() { + let run = start().await; + // Without a credential the body is never decoded: the answer is about the credential. + let s = undecodable(&run.channel, None).await; + assert_eq!(s.code(), Code::Unauthenticated, "{s:?}"); + // With one, the same body reaches the decoder and fails there. + let s = undecodable(&run.channel, Some(VIEWER)).await; + assert_ne!(s.code(), Code::Unauthenticated, "{s:?}"); + assert_ne!(s.code(), Code::Ok); +} diff --git a/crates/krishiv-session/src/access.rs b/crates/krishiv-session/src/access.rs index f6b44bb08..c47df62c1 100644 --- a/crates/krishiv-session/src/access.rs +++ b/crates/krishiv-session/src/access.rs @@ -294,9 +294,6 @@ impl Session { Ok(views) } - /// `privilege` on the permanent view `namespace.name`, besides the table-named check made - /// before dispatch: the statements that show a view's definition, columns or properties - /// need `Browse` on it (ADR 0046 §4), `SHOW TABLE EXTENDED` `Select`. /// Checks that this session's principal holds `privilege` on `securable` under the /// session's policy, outside any statement: what a server checks for a request before it /// runs one (the Connect request classes of ADR 0046 §3b). Fails closed as every check @@ -320,6 +317,9 @@ impl Session { audit(self, class, r) } + /// `privilege` on the permanent view `namespace.name`, besides the table-named check made + /// before dispatch: the statements that show a view's definition, columns or properties + /// need `Browse` on it (ADR 0046 §4), `SHOW TABLE EXTENDED` `Select`. pub(crate) async fn authorize_view( &self, namespace: &str, From 354563f0161ef0c86cb646ffc0bdaf942b5fa1fe Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:51:58 +0000 Subject: [PATCH 27/47] common, docs: config docs name no internal ADR or task; regenerate the PySpark migration guide Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-common/src/config/registry.rs | 8 ++++---- docs/guides/pyspark-migration.md | 2 ++ docs/reference/config/index.md | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/krishiv-common/src/config/registry.rs b/crates/krishiv-common/src/config/registry.rs index 0b8b6ccb2..50967c090 100644 --- a/crates/krishiv-common/src/config/registry.rs +++ b/crates/krishiv-common/src/config/registry.rs @@ -1184,7 +1184,7 @@ pub static IO_LOCAL_ALLOWED_ROOTS: ConfigEntry = ConfigEntry { default: None, doc: "Server mode (authentication configured): a comma-separated list of absolute paths of \ existing local directories (a path holding a comma cannot be listed) that reads and \ - writes by path may use, for every role, admin included (ADR 0046 §3a). A path is \ + writes by path may use, for every role, admin included. A path is \ compared after `.`, `..` and symbolic links are resolved, and a glob or directory \ read below a root fails at a link out of every root. Unset or empty: no local path is \ allowed in server mode. Embedded use is not confined by path; set there, the key only \ @@ -1203,14 +1203,14 @@ pub static IO_OBJECT_STORE_ALLOWED_PREFIXES: ConfigEntry = ConfigEntry { doc: "Server mode (authentication configured) only: a comma-separated list of object-store \ prefixes (`s3://bucket/prefix`, `gs://bucket`, \ `abfss://container@account.dfs.core.windows.net/prefix`; an Azure prefix names its \ - account host) that reads and writes by path may use, for every role, admin included \ - (ADR 0046 §3a). A prefix matches whole key segments (`s3://b/data` does not match \ + account host) that reads and writes by path may use, for every role, admin included. \ + A prefix matches whole key segments (`s3://b/data` does not match \ `s3://b/data2`) and holds no glob character; `s3` and `s3a` name the same store; a \ path holding `%`, `?` or `#` is never allowed, so `?` globs are refused. It is the one \ opt-in for the server's own (ambient) credentials and for `http(s)://` locations, which \ are refused unless listed. Any `fs.*` store option but an inline access key, secret, \ token or SAS (so endpoints, regions, accounts, key files, the GCS key JSON and tuning \ - keys alike), per read, on a table or set in a session, needs admin (P3-T067b); a \ + keys alike), per read, on a table or set in a session, needs admin; a \ table's options stored by an admin are not asked about again when others read or \ insert. Unset or empty: no object-store path \ is allowed. Embedded use and a server without authentication do not read it.", diff --git a/docs/guides/pyspark-migration.md b/docs/guides/pyspark-migration.md index 3e8744304..eb5f224c5 100644 --- a/docs/guides/pyspark-migration.md +++ b/docs/guides/pyspark-migration.md @@ -156,6 +156,8 @@ Each row links to the full description in [known differences](../concepts/known- | [Correlated subqueries with `spark.sql.optimizer.decorrelateInnerQuery.enabled` off](../concepts/known-differences.md#correlated-subqueries-with-sparksqloptimizerdecorrelateinnerqueryenabled-off) | | [SQL scripting](../concepts/known-differences.md#sql-scripting) | | [SQL function bodies and the ANSI mode they were created under](../concepts/known-differences.md#sql-function-bodies-and-the-ansi-mode-they-were-created-under) | +| [The relation cache is per session](../concepts/known-differences.md#the-relation-cache-is-per-session) | +| [Checkpoint files and where a checkpoint lives](../concepts/known-differences.md#checkpoint-files-and-where-a-checkpoint-lives) | | [`krishiv server` (Spark Connect)](../concepts/known-differences.md#krishiv-server-spark-connect) | ## Step 3: check the result diff --git a/docs/reference/config/index.md b/docs/reference/config/index.md index e79e87ce2..7c79f1235 100644 --- a/docs/reference/config/index.md +++ b/docs/reference/config/index.md @@ -63,13 +63,13 @@ Keys under `krishiv.experimental.` are experimental and off by default. Spark al | `krishiv.io.credentials.refreshSkew` | duration | `5min` | cluster | startup | 0.1.0 | | How long before its expiry a short-lived object-store credential (vended by a catalog or a token service) is refreshed. A request that arrives inside this window fetches a new credential first; an expired credential is never used. `0s` refreshes only at expiry. | | `krishiv.io.footerReadBytes` | size | `64KB` | cluster | startup | 0.1.0 | | Size of the speculative read at the end of a file that fetches its footer in one request; a format may ask for more (up to 1 MiB). | | `krishiv.io.lateMaterialization` | enum (`auto`, `on`, `off`) | `auto` | session | user | 0.1.0 | | Whether a Parquet scan evaluates its pushed filter on the filter's columns before decoding the others for the rows that pass: `auto` decides per row group from the fraction of rows the filter kept before, `on` and `off` force it. Returned rows are the same either way; with the filter, decode errors confined to rows it rejects (a corrupt page or invalid UTF-8 in a column it does not read) are not raised. | -| `krishiv.io.local.allowedRoots` | string | (none) | cluster | startup | 0.2.0 | | Server mode (authentication configured): a comma-separated list of absolute paths of existing local directories (a path holding a comma cannot be listed) that reads and writes by path may use, for every role, admin included (ADR 0046 §3a). A path is compared after `.`, `..` and symbolic links are resolved, and a glob or directory read below a root fails at a link out of every root. Unset or empty: no local path is allowed in server mode. Embedded use is not confined by path; set there, the key only makes listings refuse links out of the roots. | +| `krishiv.io.local.allowedRoots` | string | (none) | cluster | startup | 0.2.0 | | Server mode (authentication configured): a comma-separated list of absolute paths of existing local directories (a path holding a comma cannot be listed) that reads and writes by path may use, for every role, admin included. A path is compared after `.`, `..` and symbolic links are resolved, and a glob or directory read below a root fails at a link out of every root. Unset or empty: no local path is allowed in server mode. Embedded use is not confined by path; set there, the key only makes listings refuse links out of the roots. | | `krishiv.io.maxConcurrentRequests` | int | `64` | cluster | startup | 0.1.0 | | Most object-store requests in flight at once per store, over all queries of the process. The local file store also runs each request on a blocking thread of the I/O runtime, so at most `krishiv.io.blockingThreads` of them run at once. | | `krishiv.io.maxRequestBytes` | size | `16MB` | cluster | startup | 0.1.0 | | Ranges are not coalesced into requests larger than this (a single larger range is still fetched whole). | | `krishiv.io.maxRetries` | int | `5` | cluster | startup | 0.1.0 | | Retries of an object-store request that failed transiently (throttling, unavailable, timeout), with jittered exponential backoff honouring `Retry-After`. | | `krishiv.io.metadataCacheBytes` | size | `64MB` | cluster | startup | 0.1.0 | | Size of the in-memory cache of decoded file footers and metadata (least recently used entries are evicted). `0` disables it. | | `krishiv.io.multipartPartBytes` | size | `8MB` | cluster | startup | 0.1.0 | | Part size of multipart uploads (at least 5 MiB; doubled every 1,000 parts so a file never needs more than the 10,000 parts object stores allow). | -| `krishiv.io.objectStore.allowedPrefixes` | string | (none) | cluster | startup | 0.2.0 | | Server mode (authentication configured) only: a comma-separated list of object-store prefixes (`s3://bucket/prefix`, `gs://bucket`, `abfss://container@account.dfs.core.windows.net/prefix`; an Azure prefix names its account host) that reads and writes by path may use, for every role, admin included (ADR 0046 §3a). A prefix matches whole key segments (`s3://b/data` does not match `s3://b/data2`) and holds no glob character; `s3` and `s3a` name the same store; a path holding `%`, `?` or `#` is never allowed, so `?` globs are refused. It is the one opt-in for the server's own (ambient) credentials and for `http(s)://` locations, which are refused unless listed. Any `fs.*` store option but an inline access key, secret, token or SAS (so endpoints, regions, accounts, key files, the GCS key JSON and tuning keys alike), per read, on a table or set in a session, needs admin (P3-T067b); a table's options stored by an admin are not asked about again when others read or insert. Unset or empty: no object-store path is allowed. Embedded use and a server without authentication do not read it. | +| `krishiv.io.objectStore.allowedPrefixes` | string | (none) | cluster | startup | 0.2.0 | | Server mode (authentication configured) only: a comma-separated list of object-store prefixes (`s3://bucket/prefix`, `gs://bucket`, `abfss://container@account.dfs.core.windows.net/prefix`; an Azure prefix names its account host) that reads and writes by path may use, for every role, admin included. A prefix matches whole key segments (`s3://b/data` does not match `s3://b/data2`) and holds no glob character; `s3` and `s3a` name the same store; a path holding `%`, `?` or `#` is never allowed, so `?` globs are refused. It is the one opt-in for the server's own (ambient) credentials and for `http(s)://` locations, which are refused unless listed. Any `fs.*` store option but an inline access key, secret, token or SAS (so endpoints, regions, accounts, key files, the GCS key JSON and tuning keys alike), per read, on a table or set in a session, needs admin; a table's options stored by an admin are not asked about again when others read or insert. Unset or empty: no object-store path is allowed. Embedded use and a server without authentication do not read it. | | `krishiv.io.parquet.bloomFilter.enabled` | bool | `true` | session | user | 0.1.0 | | Whether a Parquet scan reads the stored Bloom filters of the row groups its statistics could not decide and skips a row group when a pushed equality or IN literal cannot be in it. Returned rows are the same either way: the filter only prunes, and a filter that fails a check is ignored. Files without Bloom filters pay nothing. | | `krishiv.io.parquet.dictionaryFilter.enabled` | bool | `true` | session | user | 0.2.0 | | Whether a Parquet scan reads the dictionary page of a column chunk whose data pages are all dictionary encoded, for the row groups its statistics could not decide, and skips a row group when the dictionary holds no literal of a pushed equality or IN. Returned rows are the same either way: the dictionary only prunes, and a page that fails a check is ignored. Dictionary pages above 16 MiB are not read. | | `krishiv.io.parquet.pageIndex.enabled` | bool | `true` | session | user | 0.1.0 | | Whether a Parquet scan reads the page index of the row groups its statistics could not decide and skips the pages the pushed filter rules out. Returned rows are the same either way: the index only prunes, and an index that fails a check is ignored. Files without a page index pay nothing. | From b988de8121e868d154e9b6b3703c04102ce8b145 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:20:34 +0000 Subject: [PATCH 28/47] changelog: fragment for server TLS, mTLS, OIDC and path confinement Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- changelog.d/733.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/733.added.md diff --git a/changelog.d/733.added.md b/changelog.d/733.added.md new file mode 100644 index 000000000..820865b7f --- /dev/null +++ b/changelog.d/733.added.md @@ -0,0 +1 @@ +`krishiv server` can serve TLS, require client certificates (mTLS) and accept OIDC bearer tokens. With authentication on, reads and writes by path are confined to `krishiv.io.local.allowedRoots` and `krishiv.io.objectStore.allowedPrefixes`, and server-wide store options need the admin role. From e4d587869e7b4e74affa46c4c4b2ba73c3d2e125 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:38:10 +0000 Subject: [PATCH 29/47] session: check a write's store options before its target store is built, so a non-admin's key file is never read Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-session/CLAUDE.md | 2 +- crates/krishiv-session/src/session/ops.rs | 27 +++++++++++++------ .../src/table_commands/writes.rs | 5 ++-- crates/krishiv-session/tests/server_egress.rs | 27 +++++++++++++++++++ 4 files changed, 50 insertions(+), 11 deletions(-) diff --git a/crates/krishiv-session/CLAUDE.md b/crates/krishiv-session/CLAUDE.md index 0fbdb7219..d4b8313ce 100644 --- a/crates/krishiv-session/CLAUDE.md +++ b/crates/krishiv-session/CLAUDE.md @@ -25,7 +25,7 @@ Paths are relative to this crate. - `src/query_fold.rs` the bookkeeping around one running query: `QueryStart` (counters read at start), `QueryFold`/`MetricStream`/`PendingFold` (the one fold of its metrics into the engine series and `system.queries` when the stream ends, fails or is dropped; holds the admission permit), `QueuedRow` (the history row of a query waiting for admission) - `src/admission.rs` the admission seam (E5): `AdmissionController::admit(&QueryRequest) -> Permit` asked once per query in `run_on_query_logged`; default `Unlimited`; a waiting `admit` shows the query `queued` in `system.queries`, a refusal closes the row `failed`, a drop while queued `cancelled`; the permit (slot, optional per-query memory budget) lives in the query's `QueryFold` and releases on drop; `tests/admission.rs` - `src/resource_groups/` (P3-T003, design `P3-T003-workload-management.md`) `ResourceGroups`, an `AdmissionController` over a Trino-shaped group file (`config.rs`: subset only, unknown/CPU/template keys are `KRISHIV_RESOURCE_GROUP_CONFIG_INVALID`), selectors (`select.rs`: user, userGroup, source, clientTags `k`/`k=v`, queryType; first match, else `defaultGroup`), and the tree with its dispatcher (`tree.rs`: one `Mutex`, nothing sent or dropped under it; `weighted_fair` by running/weight, `query_priority`; hard concurrency, `maxQueued` refusal `KRISHIV_RESOURCE_GROUP_QUEUE_FULL`, soft memory from `GroupSlot::set_reserved`, scan quota windows charged at slot drop, `KRISHIV_RESOURCE_GROUP_QUOTA_EXHAUSTED`); `Permit::slot::()` reaches the guard. Not wired yet: the config key, `source`/`statement_class` on the session's `QueryRequest`, `system.queries` columns, memory-pool priority -- `src/access.rs` `StatementInfo` / `Session::statement_info` (class + read securables with columns), write/DDL checks (`authorize_statement`), the `krishiv::audit` denial record, `PathPrefixPolicy` (local roots; object-store prefixes by store and whole key segments, `with_object_prefixes`; `from_config` is the server-mode confinement of ADR 0046 §3a from `krishiv.io.local.allowedRoots` / `krishiv.io.objectStore.allowedPrefixes`, default empty, every role, absolute roots; Azure prefixes name their account; with `allowedRoots` set the runtime's local store refuses listings that follow a link out of the roots, `SettledLocalFileSystem::confined`; `tests/path_confinement.rs`). Server-scoped store options (P3-T067b; `krishiv_io::is_server_scoped_option`: every `fs.*` key but an inline key, secret, token or SAS) need `Manage` on `System("cluster")` per read (`bind_files`, table `WITH`), per write (`authorize_write_path`; a table's own stored options are not asked again), on CREATE TABLE `OPTIONS` / `SET SERDEPROPERTIES`, on SQL `SET`, and per query while the session changed one from its builder config (`ConfStore::store_settings_changed`); `tests/server_egress.rs` +- `src/access.rs` `StatementInfo` / `Session::statement_info` (class + read securables with columns), write/DDL checks (`authorize_statement`), the `krishiv::audit` denial record, `PathPrefixPolicy` (local roots; object-store prefixes by store and whole key segments, `with_object_prefixes`; `from_config` is the server-mode confinement of ADR 0046 §3a from `krishiv.io.local.allowedRoots` / `krishiv.io.objectStore.allowedPrefixes`, default empty, every role, absolute roots; Azure prefixes name their account; with `allowedRoots` set the runtime's local store refuses listings that follow a link out of the roots, `SettledLocalFileSystem::confined`; `tests/path_confinement.rs`). Server-scoped store options (P3-T067b; `krishiv_io::is_server_scoped_option`: every `fs.*` key but an inline key, secret, token or SAS) need `Manage` on `System("cluster")` per read (`bind_files`, table `WITH`), per write (`authorize_write_options`, before the target store is built; a table's own stored options are not asked again), on CREATE TABLE `OPTIONS` / `SET SERDEPROPERTIES`, on SQL `SET`, and per query while the session changed one from its builder config (`ConfStore::store_settings_changed`); `tests/server_egress.rs` - `src/auth/` (P3-T002, design `design/P3-T002-authentication.md`, ADR 0046) server authentication: `Role` (viewer < operator < admin), `Credentials` / `Authenticated` / `AuthFailure` (Spark's two `UNAUTHENTICATED` messages), the `Authenticator` trait and `ServerAuth` (throttle, then static tokens; one `krishiv::auth` warn record and one `(surface, method)` counter per failure, never the credential); `config.rs` `AuthConfig::from_settings` (`krishiv.auth.tokens..{secret,principal,kind,role}`, `spark.connect.authenticate.token` = one admin token named `spark`, `krishiv.auth.failureThrottle.*`; unknown `krishiv.auth.*` keys refused); `secret_ref.rs` `env:`/`file:` references (a file readable by group or others refuses startup); `tokens.rs` SHA-256 digests compared in constant time against every entry (secrets >= 32 bytes, no two tokens share one); `throttle.rs` per-address failure blocks, bounded at 65,536 addresses; `mtls.rs` client certificates (`krishiv.auth.clientCertificates` off/optional/required, `clientCa`, `mtls.principalFrom` san-uri/san-dns/cn: exactly one value of that kind or refused, `mtls.userPrincipals`; roles from `krishiv.auth.roles.{viewer,operator,admin}` lists via `config.rs` `RoleLists`; a bearer token next to a certificate must name the same principal; `required` never falls back to a token); `der.rs` `CertNames::from_der`, the bounded no-recursion DER walk of the leaf's subject CN and DNS/URI SANs (fuzz target `fuzz/fuzz_targets/cert_names.rs` includes it by path); `tls_config.rs` `TlsConfig` (`krishiv.server.tls.{certFile,keyFile,minVersion}`) and `check_listener` (startup refusals: non-loopback without auth, non-loopback tokens without TLS, client certificates without TLS); `tls.rs` (feature `tls`) `ServerTls`, the rustls `ServerConfig` on aws-lc-rs with h2/http1.1 ALPN, WebPKI client verifier, a key file checked like a secret file and `reload()` swapping the certified key (a failed reload keeps the old one); `mtls_tests.rs` with in-memory handshakes against `tests/fixtures/tls/`; `oidc_config.rs` `OidcConfig` (`krishiv.auth.oidc.{issuer,audience,jwksUri,algorithms,principalClaim,roleClaim,clockSkew,jwksRefresh,allowInsecureIssuer}` and `krishiv.auth.oidc.roles.{viewer,operator,admin}`, the role-claim values by role, a namespace separate from the certificate names of `krishiv.auth.roles.*`; refused without feature `oidc`; locations https only unless the test flag, with a host, no userinfo, no fragment); `jose.rs` the pure JWS/JWK set/discovery parsers (16 KiB token cap, canonical unpadded base64url, duplicate JSON members refused, `alg` only RS256/384/512 and ES256/384, `crit` refused, header key members ignored; `check_claims`: `exp`/`nbf` with skew, exact `iss`, `aud`, principal and groups; fuzz target `fuzz/fuzz_targets/jwt_parse.rs` includes it by path); `oidc.rs` (feature `oidc`) signatures on aws-lc-rs (no `none`, no HMAC), `JwksCache` (an unknown `kid` asks for one fetch per minute, the first such request waits for it, others fail at once; a token without `kid` needs exactly one fitting key; a failed fetch or a set with no usable key keeps the keys; no keys at all is `Unavailable`) and the `krishiv-jwks` fetch thread (`HttpKeySource`: blocking reqwest, no redirects, 1 MiB cap, one 10 s deadline per fetch); the role of an OIDC principal is the highest any of its role-claim values has in the OIDC role lists; only an invalid token counts toward the throttle; `jose_tests.rs` (every build), `oidc_tests.rs` with keys generated per run, `oidc_http_tests.rs` against a loopback HTTP server; `roles.rs` `RolePolicy`, the ADR 0046 §3 matrix as an `AccessPolicy` (viewer: `Browse`, own `system.*` rows, `/metrics`; operator: `Select`, `Usage`, `Monitor`, `Operate`; admin: `Modify`/`Create`/`Drop`/`Manage`; client code at `krishiv.auth.udf.minRole`; `Impersonate` never; no role denies all), `tests/roles_matrix.rs`. Metadata statements need `Browse` (`authorize` falls back to `Select` for providers that know only `Select`); `ADMIN`-class statements need `Manage` on `System("cluster")` - `src/partitions.rs` partitioned and bucketed tables: the layout `CREATE TABLE` stores (partition columns last), the partitions the catalog tracks (`ADD PARTITION`, `SET LOCATION` and static `INSERT` values cast to the column types, `DROP PARTITION` of a managed table: log first, then the directories; each partitioned table has a read/write lock (`InMemoryCatalogStore::partition_ddl` / `partition_write`, keyed by location, weak entries): `RENAME`/`DROP PARTITION`, `MSCK REPAIR` and `TRUNCATE` hold it exclusively (across their log commit retries) and re-read the entry under it, an `INSERT` holds it shared from its write to its partition registration; external tables refused, registered after `INSERT`/CTAS), `DESCRIBE ... PARTITION`, `SHOW PARTITIONS`, `ANALYZE ... PARTITION`; `tests/partitioned_tables.rs` - `src/column_stats.rs` `ANALYZE TABLE .. FOR COLUMNS` (`CommandUtils.statExprs`): one aggregate over the table, kept in `CatalogTable::column_stats` (the catalog is in memory, no on-disk format), printed by `DESCRIBE EXTENDED t col`; a write, `TRUNCATE`, `ADD PARTITION`, `DROP PARTITION` or `SET LOCATION` drops them (`updateTableStats`); published only to the entry the aggregate read (`CatalogTable::incarnation`, set by the store at create, kept by `replace_table`), so a table dropped and created again meanwhile gets none, nor one whose data changed after it was read (`CatalogTable::data_version`, moved by `data_changed`, which every stats-clearing change calls); `MSCK REPAIR` keeps them, as Spark 4.2.0 does; `tests/char_varchar_write.rs` diff --git a/crates/krishiv-session/src/session/ops.rs b/crates/krishiv-session/src/session/ops.rs index ee682d47e..e524f7f9b 100644 --- a/crates/krishiv-session/src/session/ops.rs +++ b/crates/krishiv-session/src/session/ops.rs @@ -27,12 +27,12 @@ impl Session { crate::access::physical(at) } - /// Before anything is written or replaced (an overwrite deletes first): - /// `Modify` and `Create` on the path, both whether or not it exists, so a - /// denial never tells whether it does. - pub(crate) async fn authorize_write_path( + /// A write's options, before its target is located: locating builds the + /// store from them (a key file is read then), so a server-scoped option is + /// refused before any store sees it. `USAGE` on the secrets the encoder + /// resolves, then `Manage` for a server-scoped store key. + pub(crate) async fn authorize_write_options( &self, - at: &krishiv_exec::files::LocatedPath, options: &BTreeMap, table_options: &BTreeMap, ) -> Result<(), KrishivError> { @@ -53,7 +53,17 @@ impl Session { .filter(|(k, v)| table_options.get(*k) != Some(*v)) .map(|(k, _)| k.as_str()), ) - .await?; + .await + } + + /// Before anything is written or replaced (an overwrite deletes first): + /// `Modify` and `Create` on the path, both whether or not it exists, so a + /// denial never tells whether it does. The options were checked before the + /// path was located (`authorize_write_options`). + pub(crate) async fn authorize_write_path( + &self, + at: &krishiv_exec::files::LocatedPath, + ) -> Result<(), KrishivError> { let securable = Securable::Path(OptionValue::from_user("path", at.display.clone())); for privilege in [Privilege::Modify, Privilege::Create] { let r = authorize( @@ -213,11 +223,12 @@ impl Session { let dynamic = !partitions.is_empty() && partition_overwrite_is_dynamic(&env.config, &options)?; let max_records = max_records_per_file(&options, format.name())?; + self.authorize_write_options(&options, &table_options) + .await?; let at = self.locate_write_path(text, &options, env)?; // The query's own context: its counters are the write's bytes. let io = env.io.clone(); - self.authorize_write_path(&at, &options, &table_options) - .await?; + self.authorize_write_path(&at).await?; // A link swapped into the checked path since fails the write // before anything is replaced, and again before the commit deletes // what an overwrite replaces. diff --git a/crates/krishiv-session/src/table_commands/writes.rs b/crates/krishiv-session/src/table_commands/writes.rs index 245eeec01..d989b967b 100644 --- a/crates/krishiv-session/src/table_commands/writes.rs +++ b/crates/krishiv-session/src/table_commands/writes.rs @@ -500,10 +500,11 @@ impl Session { // both before the drop. let location = managed_location(&self.warehouse_dir()?, &name.namespace, &name.name); + self.authorize_write_options(&spec.options, &BTreeMap::new()) + .await?; let at = self.locate_write_path(&location.display().to_string(), &spec.options, &env)?; - self.authorize_write_path(&at, &spec.options, &BTreeMap::new()) - .await?; + self.authorize_write_path(&at).await?; // A managed table's own directory goes with it; any other one in the way stays. let own = self .state diff --git a/crates/krishiv-session/tests/server_egress.rs b/crates/krishiv-session/tests/server_egress.rs index 7dab3da29..799fee1a9 100644 --- a/crates/krishiv-session/tests/server_egress.rs +++ b/crates/krishiv-session/tests/server_egress.rs @@ -327,6 +327,33 @@ fn an_endpoint_write_option_needs_manage() { assert_eq!(write(&[]), Ok(0)); } +/// The write options are checked before the target's store is built: building a GCS store reads +/// the key file it names, so a non-admin naming one gets the denial, never the builder's error +/// about the file. +#[cfg(feature = "cloud")] +#[test] +fn a_key_file_write_option_is_refused_before_the_store_reads_it() { + let tmp = tempfile::tempdir().unwrap(); + let s = Session::builder() + .principal(Principal::new("w", PrincipalKind::User)) + .access_policy(Arc::new(WriterWithoutManage)) + .build() + .unwrap(); + let missing = tmp.path().join("no-such-key.json"); + let w = block_on(s.sql("SELECT 1 AS a")) + .unwrap() + .write() + .mode("overwrite") + .option( + "fs.gs.auth.service.account.json.keyfile", + missing.to_str().unwrap(), + ); + assert_eq!( + outcome(block_on(w.csv("gs://bucket/out")).map(|()| 0)), + Err(DENIED.to_owned()) + ); +} + /// `http(s)://` locations are refused for every role until an admin lists one in /// `allowedPrefixes` (the one opt-in); then the role decides. #[test] From 0ac755b4a530569b180622268c6fe9fa068509ac Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:27:10 +0000 Subject: [PATCH 30/47] =?UTF-8?q?server,=20connect-server,=20cli:=20TLS=20?= =?UTF-8?q?on=20both=20listeners,=20client=20certificates=20to=20the=20aut?= =?UTF-8?q?henticator,=20SIGHUP=20reload,=20check=5Flistener=20at=20startu?= =?UTF-8?q?p=20(P3-T002=20PR-14,=20design=20=C2=A73.4,=20=C2=A73.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- Cargo.lock | 4 + Cargo.toml | 4 + crates/krishiv-cli/CLAUDE.md | 2 +- crates/krishiv-cli/Cargo.toml | 3 + crates/krishiv-cli/src/server.rs | 167 +++++++++++--- crates/krishiv-cli/tests/server.rs | 169 ++++++++++++++- crates/krishiv-connect-server/CLAUDE.md | 2 +- crates/krishiv-connect-server/Cargo.toml | 5 +- crates/krishiv-connect-server/src/auth.rs | 5 +- .../krishiv-connect-server/src/auth/layer.rs | 11 +- .../krishiv-connect-server/src/auth/tests.rs | 2 +- crates/krishiv-connect-server/src/host.rs | 24 +-- crates/krishiv-connect-server/src/server.rs | 169 ++++++++++++--- crates/krishiv-connect-server/tests/tls.rs | 181 ++++++++++++++++ crates/krishiv-server/CLAUDE.md | 3 +- crates/krishiv-server/Cargo.toml | 3 + crates/krishiv-server/src/guard.rs | 59 ++++- crates/krishiv-server/src/internal.rs | 7 +- crates/krishiv-server/src/lib.rs | 2 + crates/krishiv-server/src/metrics.rs | 7 +- crates/krishiv-server/src/session_guard.rs | 1 + crates/krishiv-server/src/tls.rs | 203 +++++++++++++++++ crates/krishiv-server/tests/tls.rs | 204 ++++++++++++++++++ 23 files changed, 1125 insertions(+), 112 deletions(-) create mode 100644 crates/krishiv-connect-server/tests/tls.rs create mode 100644 crates/krishiv-server/src/tls.rs create mode 100644 crates/krishiv-server/tests/tls.rs diff --git a/Cargo.lock b/Cargo.lock index 333345cb5..057f3452c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2241,6 +2241,7 @@ dependencies = [ "libmimalloc-sys", "mimalloc", "reedline", + "rustls", "serde_json", "tempfile", "tokio", @@ -2282,6 +2283,7 @@ dependencies = [ "bytes", "futures", "getrandom 0.4.3", + "hyper-util", "krishiv-common", "krishiv-connect", "krishiv-memory", @@ -2295,6 +2297,7 @@ dependencies = [ "sha2", "tempfile", "tokio", + "tokio-rustls", "tonic", "tonic-prost", "tower", @@ -2620,6 +2623,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "tokio-rustls", "tracing", ] diff --git a/Cargo.toml b/Cargo.toml index e439a0c5a..b4a9e608f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -168,10 +168,14 @@ rdkafka = { version = "0.39", default-features = false } wasmtime = { version = "49", default-features = false } axum = { version = "0.8.9", default-features = false } tower = { version = "0.5.3", default-features = false } +# Test clients only: `TokioIo` for a tonic channel over a TLS stream. +hyper-util = { version = "0.1.21", default-features = false } rust-embed = { version = "8.13", default-features = false } utoipa = { version = "6.0", default-features = false } rmcp = { version = "3.5.1", default-features = false } rustls = { version = "0.23", default-features = false } +# Server TLS on the listeners (P3-T002 design §3.5): the async acceptor over `rustls`. +tokio-rustls = { version = "0.26.6", default-features = false } # OIDC JWT signatures (krishiv-session `oidc`); already in the tree as rustls's crypto provider. aws-lc-rs = { version = "1.18.2", default-features = false, features = ["aws-lc-sys"] } tracing = { version = "0.1.44", default-features = false } diff --git a/crates/krishiv-cli/CLAUDE.md b/crates/krishiv-cli/CLAUDE.md index f0e59f215..a741964e8 100644 --- a/crates/krishiv-cli/CLAUDE.md +++ b/crates/krishiv-cli/CLAUDE.md @@ -10,7 +10,7 @@ The `krishiv` binary: sql, server, coordinator, worker, mcp, ui, doctor, migrate Paths are relative to this crate. - `src/main.rs` subcommand dispatch and the allocator/memory hook set-up - `src/sql.rs` the `krishiv sql` subcommand: input (`-e`, `-f`, stdin), output (`-o`), `--conf`, `--table`, `--timing`, `--agent`, `--max-rows`, `--max-seconds` -- `src/server.rs` `krishiv server` (feature `server`; `ui` implies it; design P3-T001): host checks before binding (non-loopback refused until P3-T002, root refused without `--allow-root`, core dumps off unless `krishiv.server.allowCoreDumps`), one tokio runtime for the Spark Connect listener (`krishiv-connect-server`, `--connect-port`, default 15002) and the admin listener (`--port` 8080: `/healthz`, `/readyz`, `/metrics` and the UI with `ui`; with `krishiv.auth.*` tokens configured they are guarded by `SessionGuard` over one `ServerAuth`, else by a random token printed once; `krishiv.auth.*` settings never reach the engine configuration). With authentication on, the same `ServerAuth` authenticates every Connect RPC (`ConnectAuth`) and each caller's session is `Session::new_session_as(principal, PathPrefixPolicy(RolePolicy(role)))` (`krishiv_connect_server::role_sessions`; client code admin-only until T014b, ADR 0046 N1; the path confinement is checked at startup; a caller with no role is refused before any session), SIGTERM/SIGINT drain then exit 0 after the engine runtime is dropped, a second signal exits 130; `tests/server.rs` +- `src/server.rs` `krishiv server` (feature `server`; `ui` implies it; design P3-T001): host checks before binding (`check_listener`: non-loopback needs `krishiv.auth.*`, bearer methods there need TLS, client certificates need TLS; root refused without `--allow-root`, core dumps off unless `krishiv.server.allowCoreDumps`), one tokio runtime for the Spark Connect listener (`krishiv-connect-server`, `--connect-port`, default 15002) and the admin listener (`--port` 8080: `/healthz`, `/readyz`, `/metrics` and the UI with `ui`; with `krishiv.auth.*` tokens configured they are guarded by `SessionGuard` over one `ServerAuth`, else by a random token printed once; `krishiv.auth.*` and `krishiv.server.tls.*` settings never reach the engine configuration; with `krishiv.server.tls.certFile`/`keyFile` both listeners speak TLS through one `ServerTls`, reloaded on SIGHUP (handler installed before the addresses print), and the printed addresses are `sc://...;use_ssl=true` / `https://`). With authentication on, the same `ServerAuth` authenticates every Connect RPC (`ConnectAuth`) and each caller's session is `Session::new_session_as(principal, PathPrefixPolicy(RolePolicy(role)))` (`krishiv_connect_server::role_sessions`; client code admin-only until T014b, ADR 0046 N1; the path confinement is checked at startup; a caller with no role is refused before any session), SIGTERM/SIGINT drain then exit 0 after the engine runtime is dropped, a second signal exits 130; `tests/server.rs` - `src/check_compat/` (P3-T029) `krishiv check-compat `: `mod.rs` args, file walk, exit codes (0 none, 1 findings, 2 read errors), JSON/text report; `scan.rs` SQL / Python / notebook-cell scanners; `alternatives.json` workarounds for not-yet functions (names only supported functions; a unit test checks), `functions.json` the function matrix (generated from the SQL reference pages by `tests/scripts/gen_compat_matrix.py`); corpus `tests/check_compat_corpus/` (`handwritten/` with a hand-checked `expected.json`, `supported_only/` must give no findings, `generated/` from `tests/scripts/gen_check_compat_corpus.py`); JSON report is `{schema_version, files_scanned, findings, errors}` - `src/repl.rs` the interactive loop (no `-e`/`-f` on a terminal): `.timer`, `.format`, `.complete`, `.history`, history persistence - `src/complete.rs` completion sources (keywords, function registry, session catalog, config registry) diff --git a/crates/krishiv-cli/Cargo.toml b/crates/krishiv-cli/Cargo.toml index 1e5001acb..5ba01c60c 100644 --- a/crates/krishiv-cli/Cargo.toml +++ b/crates/krishiv-cli/Cargo.toml @@ -35,6 +35,7 @@ serde_json = { workspace = true } mimalloc = { workspace = true, optional = true } [dev-dependencies] +rustls = { workspace = true, features = ["aws_lc_rs", "std", "tls12"] } tempfile.workspace = true libmimalloc-sys = { workspace = true, features = ["extended"] } @@ -59,7 +60,9 @@ server = [ "dep:krishiv-server", "krishiv-server/session", "krishiv-server/metrics-prometheus", + "krishiv-server/tls", "dep:krishiv-session", + "krishiv-session/tls", "dep:krishiv-connect-server", "dep:axum", "dep:tokio", diff --git a/crates/krishiv-cli/src/server.rs b/crates/krishiv-cli/src/server.rs index f97b527f0..b94e3b8af 100644 --- a/crates/krishiv-cli/src/server.rs +++ b/crates/krishiv-cli/src/server.rs @@ -6,10 +6,14 @@ //! `krishiv.auth.tokens.*` configured the admin routes take those principals //! and the ADR 0046 roles (P3-T002); without, a random token printed once. //! -//! Before anything binds: a non-loopback address is refused (no -//! authentication exists until P3-T002), uid 0 is refused without -//! `--allow-root`, and core dumps are turned off unless -//! `krishiv.server.allowCoreDumps`. On SIGTERM or SIGINT the server drains +//! Before anything binds: the address, authentication and TLS must fit +//! together (`check_listener`, design P3-T002 §3.5: a non-loopback address +//! needs an authentication method, a bearer method there needs TLS, client +//! certificates need TLS), uid 0 is refused without `--allow-root`, and +//! core dumps are turned off unless `krishiv.server.allowCoreDumps`. With +//! `krishiv.server.tls.certFile`/`keyFile` both listeners speak TLS; on +//! SIGHUP (Unix) the certificate and key are read again, and a failed read +//! keeps the old ones serving. On SIGTERM or SIGINT the server drains //! (`/readyz` 503, new RPCs `UNAVAILABLE`), gives running queries //! `krishiv.server.shutdownTimeout`, closes every session, and returns; the //! engine runtime is dropped before `main` exits, which removes the spill @@ -30,7 +34,9 @@ use krishiv_connect_server::{ }; use krishiv_server::guard::{Guard, TokenGuard}; use krishiv_server::session_guard::SessionGuard; -use krishiv_session::auth::{AuthConfig, ServerAuth, is_auth_key}; +use krishiv_session::auth::{ + AuthConfig, ServerAuth, ServerTls, TlsConfig, check_listener, is_auth_key, +}; use krishiv_session::{EngineRuntime, Session}; use tokio::net::TcpListener; use tokio::sync::watch; @@ -45,7 +51,8 @@ pub(crate) struct ServerArgs { #[arg(long = "connect-port")] connect_port: Option, /// Address both listeners bind; default `krishiv.connect.address`, loopback. A - /// non-loopback address needs authentication, which this build cannot configure yet. + /// non-loopback address needs authentication (`krishiv.auth.*`), and TLS + /// (`krishiv.server.tls.*`) when that is a bearer token or OIDC. #[arg(long)] host: Option, /// Run even as uid 0. @@ -71,18 +78,18 @@ pub(crate) fn run(args: &ServerArgs) -> i32 { fn serve(args: &ServerArgs) -> Result<(), String> { let mut config = Config::new(); let mut builder = EngineRuntime::builder(); - // Authentication settings (secret references included) are read once here and never become - // engine configuration: a session could otherwise list them with `SET`. - let auth = AuthConfig::from_settings( + // Authentication and TLS settings (secret references and key files included) are read once + // here and never become engine configuration: a session could otherwise list them with `SET`. + let listener_settings = || { args.conf .iter() .map(|(k, v)| (k.as_str(), v.as_str())) - .filter(|(k, _)| is_auth_key(k)), - ) - .and_then(|c| ServerAuth::new(&c)) - .map_err(|e| e.to_string())?; + .filter(|(k, _)| is_listener_key(k)) + }; + let auth_config = AuthConfig::from_settings(listener_settings()).map_err(|e| e.to_string())?; + let tls_config = TlsConfig::from_settings(listener_settings()).map_err(|e| e.to_string())?; for (k, v) in &args.conf { - if is_auth_key(k) { + if is_listener_key(k) { continue; } let warning = config.set(k, v).map_err(|e| e.to_string())?; @@ -107,14 +114,16 @@ fn serve(args: &ServerArgs) -> Result<(), String> { .parse() .map_err(|_| "krishiv.connect.address is not an IP address".to_owned())?, }; - // ADR 0046 §6: before either listener binds. - if host::needs_auth(host) { - return Err(format!( - "refusing to serve on {host} without authentication: a non-loopback address needs \ - authentication configured (P3-T002, not in this build). Bind a loopback address \ - (--host 127.0.0.1) and put an authenticated tunnel or proxy in front of it." - )); - } + // ADR 0046 §6, design §3.5: before either listener binds (both bind `host`). + check_listener(SocketAddr::new(host, 0), &auth_config, &tls_config).map_err(|e| { + format!( + "refusing to serve on {host}: {e}. Bind a loopback address (--host 127.0.0.1), or \ + configure authentication (krishiv.auth.*) and, for tokens or OIDC, TLS \ + (krishiv.server.tls.certFile and keyFile)." + ) + })?; + let tls = ServerTls::new(&tls_config, &auth_config.mtls).map_err(|e| e.to_string())?; + let auth = ServerAuth::new(&auth_config).map_err(|e| e.to_string())?; if host::is_root() && !args.allow_root { return Err("refusing to run as root; pass --allow-root to override".to_owned()); } @@ -141,6 +150,7 @@ fn serve(args: &ServerArgs) -> Result<(), String> { runtime, &config, auth, + tls, SocketAddr::new(host, connect_port), SocketAddr::new(host, args.port), ) @@ -152,6 +162,12 @@ fn serve(args: &ServerArgs) -> Result<(), String> { result } +/// A setting `krishiv server` reads itself: authentication (`krishiv.auth.*`, Spark's token key) +/// and the listeners' TLS (`krishiv.server.tls.*`). +fn is_listener_key(key: &str) -> bool { + is_auth_key(key) || key.starts_with("krishiv.server.tls.") +} + fn flag(config: &Config, entry: &krishiv_common::config::ConfigEntry, default: bool) -> bool { config .get(entry) @@ -167,6 +183,7 @@ async fn run_listeners( runtime: Arc, config: &Config, auth: ServerAuth, + tls: Option, connect_addr: SocketAddr, admin_addr: SocketAddr, ) -> Result<(), String> { @@ -218,6 +235,9 @@ async fn run_listeners( if authenticated { connect = connect.with_auth(ConnectAuth::new(auth)); } + if let Some(tls) = &tls { + connect = connect.with_tls(&tls.server_config()); + } let (stop_tx, stop_rx) = watch::channel(false); // Installed before the addresses are printed: a SIGTERM right after @@ -228,14 +248,41 @@ async fn run_listeners( let stopped = |mut rx: watch::Receiver| async move { let _ = rx.wait_for(|stop| *stop).await; }; - let admin = tokio::spawn(krishiv_server::admin::serve_capped( - admin_listener, - app, - krishiv_server::admin::HttpLimits::default().max_connections, - stopped(stop_rx.clone()), - )); - println!("krishiv server: Spark Connect on sc://{connect_bound}"); - println!("krishiv server: admin on http://{admin_bound}"); + let max_admin = krishiv_server::admin::HttpLimits::default().max_connections; + let admin = match &tls { + Some(tls) => { + let config = tls.server_config(); + let shutdown = stopped(stop_rx.clone()); + tokio::spawn(async move { + krishiv_server::tls::serve_capped_tls( + admin_listener, + app, + max_admin, + &config, + shutdown, + ) + .await + }) + } + None => tokio::spawn(krishiv_server::admin::serve_capped( + admin_listener, + app, + max_admin, + stopped(stop_rx.clone()), + )), + }; + // The SIGHUP handler is installed here, before the addresses are printed: a SIGHUP after + // readiness must reload, not end the process (SIGHUP's default action). + let reloads = tls + .clone() + .map(|tls| tokio::spawn(reload_on_hangup(tls, Hangups::install()))); + let (sc_suffix, http) = if tls.is_some() { + ("/;use_ssl=true", "https") + } else { + ("", "http") + }; + println!("krishiv server: Spark Connect on sc://{connect_bound}{sc_suffix}"); + println!("krishiv server: admin on {http}://{admin_bound}"); let ui = flag(config, &UI_ENABLED, true) && cfg!(feature = "ui"); if authenticated { println!( @@ -243,7 +290,7 @@ async fn run_listeners( (krishiv.auth.*)" ); } else if ui { - println!("open http://{admin_bound}/?token={token}"); + println!("open {http}://{admin_bound}/?token={token}"); } else if metrics_need_token(config, admin_bound) { // `/metrics` is behind the admin token: print it once. println!("krishiv server: admin token {token}"); @@ -255,6 +302,9 @@ async fn run_listeners( .map_err(|e| e.to_string()); ready.store(false, Ordering::Release); signals.abort(); + if let Some(reloads) = reloads { + reloads.abort(); + } // The admin listener stopped accepting with the same signal; requests in // flight get `ADMIN_DRAIN` to finish. Open event streams never end on // their own, so whatever is left then is aborted. @@ -312,6 +362,61 @@ fn metrics_need_token(config: &Config, bound: SocketAddr) -> bool { /// How long admin requests in flight may take once the server stops. const ADMIN_DRAIN: Duration = Duration::from_secs(2); +/// SIGHUP, registered when installed (tokio registers a handler only when its stream is +/// created). `None` elsewhere than Unix, or when it cannot be installed. +struct Hangups { + #[cfg(unix)] + signal: Option, +} + +impl Hangups { + #[expect(clippy::print_stderr, reason = "CLI binary: stderr is its interface")] + fn install() -> Self { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + let signal = signal(SignalKind::hangup()).ok(); + if signal.is_none() { + eprintln!("krishiv server: cannot install the SIGHUP handler; TLS reload is off"); + } + Self { signal } + } + #[cfg(not(unix))] + { + Self {} + } + } +} + +/// Reads the TLS certificate and key again on every SIGHUP (Unix; elsewhere it waits forever). +/// New handshakes get the new material; a failed read is logged and the old material serves on. +#[expect(clippy::print_stderr, reason = "CLI binary: stderr is its interface")] +async fn reload_on_hangup(tls: ServerTls, hangups: Hangups) { + #[cfg(unix)] + { + let Some(mut hangups) = hangups.signal else { + return; + }; + while hangups.recv().await.is_some() { + let tls = tls.clone(); + match tokio::task::spawn_blocking(move || tls.reload()).await { + Ok(Ok(())) => eprintln!("krishiv server: TLS certificate reloaded"), + Ok(Err(e)) => { + eprintln!( + "krishiv server: TLS reload failed, the old certificate serves on: {e}" + ); + } + Err(e) => eprintln!("krishiv server: TLS reload did not finish: {e}"), + } + } + } + #[cfg(not(unix))] + { + let _ = (tls, hangups); + std::future::pending::<()>().await; + } +} + /// The first SIGTERM or SIGINT starts the drain; a second exits at once. #[expect(clippy::print_stderr, reason = "CLI binary: stderr is its interface")] async fn watch_signals(mut signals: Signals, ready: Arc, stop: watch::Sender) { diff --git a/crates/krishiv-cli/tests/server.rs b/crates/krishiv-cli/tests/server.rs index 57169b304..9ee475d0a 100644 --- a/crates/krishiv-cli/tests/server.rs +++ b/crates/krishiv-cli/tests/server.rs @@ -70,7 +70,7 @@ fn non_loopback_requires_auth() { .expect("run krishiv server"); assert_eq!(out.status.code(), Some(1), "{host}"); let err = String::from_utf8_lossy(&out.stderr); - assert!(err.contains("without authentication"), "{err}"); + assert!(err.contains("needs an authentication method"), "{err}"); assert!(err.contains("--host"), "{err}"); assert!(!String::from_utf8_lossy(&out.stdout).contains("Spark Connect on")); } @@ -101,7 +101,7 @@ fn non_loopback_requires_auth() { .output() .expect("run krishiv server"); assert_eq!(out.status.code(), Some(1)); - assert!(String::from_utf8_lossy(&out.stderr).contains("without authentication")); + assert!(String::from_utf8_lossy(&out.stderr).contains("needs an authentication method")); } /// A running server and the addresses it printed; killed and reaped on @@ -151,11 +151,14 @@ fn start_with(spill: &std::path::Path, conf: &[String]) -> Running { .next() .expect("server printed its addresses") .expect("utf-8"); - if let Some(a) = line.strip_prefix("krishiv server: admin on http://") { + if let Some(a) = line + .strip_prefix("krishiv server: admin on http://") + .or_else(|| line.strip_prefix("krishiv server: admin on https://")) + { admin = Some(a.to_owned()); } if let Some(a) = line.strip_prefix("krishiv server: Spark Connect on sc://") { - connect = Some(a.to_owned()); + connect = Some(a.trim_end_matches("/;use_ssl=true").to_owned()); } } Running { @@ -372,3 +375,161 @@ fn configured_tokens_guard_the_admin_routes() { let probe = http_get(&server.admin, "/healthz"); assert!(probe.starts_with("HTTP/1.1 200"), "{probe}"); } + +#[cfg(unix)] +#[test] +fn tokens_on_a_public_address_need_tls() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("t"); + std::fs::write(&path, "public-address-token-0123456789abcdef").expect("write secret"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("chmod"); + let out = krishiv() + .args([ + "server", + "--port", + "0", + "--connect-port", + "0", + "--host", + "0.0.0.0", + ]) + .arg("--conf") + .arg(format!( + "krishiv.auth.tokens.a.secret=file:{}", + path.display() + )) + .args(["--conf", "krishiv.auth.tokens.a.principal=a"]) + .args(["--conf", "krishiv.auth.tokens.a.role=viewer"]) + .output() + .expect("run krishiv server"); + assert_eq!(out.status.code(), Some(1)); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("need TLS"), "{err}"); + assert!(!String::from_utf8_lossy(&out.stdout).contains("Spark Connect on")); +} + +#[cfg(unix)] +/// The TLS fixtures of `krishiv-session` (`tests/fixtures/tls/README.md`). +fn tls_fixture(name: &str) -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../krishiv-session/tests/fixtures/tls") + .join(name) +} + +/// A TLS connection to `addr`, trusting the test CA, with its handshake done. +#[cfg(unix)] +fn tls_connect(addr: &str) -> rustls::StreamOwned { + use rustls::pki_types::pem::PemObject as _; + use rustls::pki_types::{CertificateDer, ServerName}; + let mut roots = rustls::RootCertStore::empty(); + roots + .add(CertificateDer::from_pem_file(tls_fixture("ca.pem")).expect("ca")) + .expect("add ca"); + let config = rustls::ClientConfig::builder_with_provider(std::sync::Arc::new( + rustls::crypto::aws_lc_rs::default_provider(), + )) + .with_safe_default_protocol_versions() + .expect("versions") + .with_root_certificates(roots) + .with_no_client_auth(); + let name = ServerName::try_from("localhost").expect("name"); + let mut conn = + rustls::ClientConnection::new(std::sync::Arc::new(config), name).expect("client"); + let mut tcp = TcpStream::connect(addr).expect("connect"); + tcp.set_read_timeout(Some(Duration::from_secs(10))) + .expect("timeout"); + while conn.is_handshaking() { + conn.complete_io(&mut tcp).expect("handshake"); + } + rustls::StreamOwned::new(conn, tcp) +} + +/// The leaf certificate the server presented on `tls`, and the response to `GET /healthz` over +/// that connection. +#[cfg(unix)] +fn tls_healthz_on( + mut tls: rustls::StreamOwned, +) -> (Vec, String) { + tls.write_all(b"GET /healthz HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") + .expect("write"); + let mut out = Vec::new(); + // The server may close without `close_notify`; keep what came. + let _ = tls.read_to_end(&mut out); + let leaf = tls + .conn + .peer_certificates() + .and_then(<[_]>::first) + .expect("server certificate") + .to_vec(); + (leaf, String::from_utf8_lossy(&out).into_owned()) +} + +/// [`tls_healthz_on`] over a new connection to `addr`. +#[cfg(unix)] +fn tls_healthz(addr: &str) -> (Vec, String) { + tls_healthz_on(tls_connect(addr)) +} + +#[cfg(unix)] +#[test] +fn tls_serves_the_admin_listener_and_reloads_on_sighup() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().expect("tempdir"); + let (cert, key) = (dir.path().join("server.pem"), dir.path().join("server.key")); + std::fs::copy(tls_fixture("server.pem"), &cert).expect("cert"); + std::fs::copy(tls_fixture("server.key"), &key).expect("key"); + std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o600)).expect("chmod"); + let conf = [ + format!("krishiv.server.tls.certFile={}", cert.display()), + format!("krishiv.server.tls.keyFile={}", key.display()), + ]; + let mut server = start_with(dir.path(), &conf); + let (leaf, answer) = tls_healthz(&server.admin); + assert!(answer.starts_with("HTTP/1.1 200"), "{answer}"); + let first = std::fs::read(tls_fixture("server.der")).expect("der"); + assert_eq!(leaf, first); + // Plain HTTP on the TLS port gets no HTTP answer. + let mut plain = TcpStream::connect(&server.admin).expect("connect"); + plain + .set_read_timeout(Some(Duration::from_secs(10))) + .expect("timeout"); + let _ = plain.write_all(b"GET /healthz HTTP/1.1\r\nHost: x\r\n\r\n"); + let mut out = Vec::new(); + let _ = plain.read_to_end(&mut out); + assert!( + !out.starts_with(b"HTTP/"), + "{:?}", + String::from_utf8_lossy(&out) + ); + // SIGHUP: the second certificate is served to new connections; one opened before keeps + // its handshake and is served. + let before = tls_connect(&server.admin); + std::fs::copy(tls_fixture("server2.pem"), &cert).expect("cert 2"); + std::fs::copy(tls_fixture("server2.key"), &key).expect("key 2"); + // `copy` gives the file the fixture's mode; a key readable by others is refused. + std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o600)).expect("chmod"); + let second = std::fs::read(tls_fixture("server2.der")).expect("der 2"); + let hup = Command::new("kill") + .args(["-HUP", &server.child.id().to_string()]) + .status() + .expect("kill -HUP"); + assert!(hup.success()); + let start = Instant::now(); + loop { + let (leaf, answer) = tls_healthz(&server.admin); + assert!(answer.starts_with("HTTP/1.1 200"), "{answer}"); + if leaf == second { + break; + } + assert!( + start.elapsed() < Duration::from_secs(10), + "no reload after SIGHUP" + ); + std::thread::sleep(Duration::from_millis(50)); + } + let (leaf, answer) = tls_healthz_on(before); + assert!(answer.starts_with("HTTP/1.1 200"), "{answer}"); + assert_eq!(leaf, first, "the old connection keeps its certificate"); + let _ = server.child.kill(); +} diff --git a/crates/krishiv-connect-server/CLAUDE.md b/crates/krishiv-connect-server/CLAUDE.md index fb08daabb..d8d149d8d 100644 --- a/crates/krishiv-connect-server/CLAUDE.md +++ b/crates/krishiv-connect-server/CLAUDE.md @@ -49,7 +49,7 @@ Paths are relative to this crate. (LRU, no expiry), `SESSION_CHANGED` on a changed server-side id, the idle sweep (skips a session running a query or holding a live operation; `busy()` is what shutdown waits for), `krishiv.connect.maxSessions` / `maxSessionsPerPrincipal` -- `src/server.rs` `ConnectServer::serve`: HTTP/2 limits, connection cap, sweeper task, shutdown order +- `src/server.rs` `ConnectServer::serve`: HTTP/2 limits, connection cap, sweeper task, shutdown order; `with_tls` (P3-T002 §3.5): the TLS handshake (ALPN `h2`) on the connection's own task within `handshakeTimeout`, `PeerInfo` (address and verified client leaf) as the connection's `ConnectInfo`, which `AuthLayer` passes to the authenticator; `tests/tls.rs` - `tests/server.rs`, `tests/execute.rs`, `tests/auth.rs` end to end over a raw tonic client ## Invariants diff --git a/crates/krishiv-connect-server/Cargo.toml b/crates/krishiv-connect-server/Cargo.toml index 3fec3c794..245e8fd56 100644 --- a/crates/krishiv-connect-server/Cargo.toml +++ b/crates/krishiv-connect-server/Cargo.toml @@ -21,7 +21,7 @@ krishiv-common = { path = "../krishiv-common", version = "=0.2.0" } krishiv-connect = { path = "../krishiv-connect", version = "=0.2.0" } krishiv-memory = { path = "../krishiv-memory", version = "=0.2.0" } krishiv-plan = { path = "../krishiv-plan", version = "=0.2.0" } -krishiv-session = { path = "../krishiv-session", version = "=0.2.0", features = ["unstable-ir"] } +krishiv-session = { path = "../krishiv-session", version = "=0.2.0", features = ["unstable-ir", "tls"] } krishiv-types = { path = "../krishiv-types", version = "=0.2.0" } getrandom.workspace = true prost = { workspace = true, features = ["derive", "std"] } @@ -30,12 +30,15 @@ rustix.workspace = true serde_json = { workspace = true, features = ["std"] } sha2.workspace = true tokio = { workspace = true, features = ["net", "rt", "sync", "time", "macros"] } +tokio-rustls.workspace = true tonic = { workspace = true, features = ["server", "router", "codegen"] } tower.workspace = true tracing.workspace = true zstd.workspace = true [dev-dependencies] +hyper-util = { workspace = true, features = ["tokio"] } +tower = { workspace = true, features = ["util"] } tempfile.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "time"] } tonic = { workspace = true, features = ["channel", "server", "router", "codegen"] } diff --git a/crates/krishiv-connect-server/src/auth.rs b/crates/krishiv-connect-server/src/auth.rs index f4b23ec79..781bbb2c9 100644 --- a/crates/krishiv-connect-server/src/auth.rs +++ b/crates/krishiv-connect-server/src/auth.rs @@ -117,7 +117,8 @@ impl ConnectAuth { Authenticated::new(self.local.clone(), None, Method::None) } - /// Authenticates one request from its `headers` and `peer` address ([`AuthLayer`] calls + /// Authenticates one request from its `headers`, `peer` address and, over TLS, the client + /// certificate rustls verified (`peer_leaf`) ([`AuthLayer`] calls /// it before the body is read). /// /// # Errors @@ -129,6 +130,7 @@ impl ConnectAuth { &self, headers: &HeaderMap, peer: Option, + peer_leaf: Option<&[u8]>, ) -> Result { let Some(authenticator) = &self.authenticator else { return Ok(self.local()); @@ -142,6 +144,7 @@ impl ConnectAuth { return Err(failure_status(AuthFailure::Missing)); }; let mut credentials = Credentials::new(peer, Surface::Connect); + credentials.peer_leaf = peer_leaf; let mut values = headers.get_all(AUTHORIZATION).iter(); match (values.next(), values.next()) { (None, _) => {} diff --git a/crates/krishiv-connect-server/src/auth/layer.rs b/crates/krishiv-connect-server/src/auth/layer.rs index db60b1faa..4449ea080 100644 --- a/crates/krishiv-connect-server/src/auth/layer.rs +++ b/crates/krishiv-connect-server/src/auth/layer.rs @@ -11,10 +11,10 @@ use std::sync::Arc; use std::task::{Context, Poll}; use tonic::codegen::http::{Request, Response}; -use tonic::transport::server::TcpConnectInfo; use tower::Service; use super::ConnectAuth; +use crate::server::PeerInfo; /// Authenticates every request before the wrapped service sees it. #[derive(Debug, Clone)] @@ -73,11 +73,10 @@ where let mut inner = std::mem::replace(&mut self.inner, clone); let auth = Arc::clone(&self.auth); Box::pin(async move { - let peer = request - .extensions() - .get::() - .and_then(TcpConnectInfo::remote_addr); - match auth.authenticate(request.headers(), peer).await { + let info = request.extensions().get::().cloned(); + let peer = info.as_ref().and_then(|i| i.remote); + let leaf = info.as_ref().and_then(|i| i.leaf.as_deref()); + match auth.authenticate(request.headers(), peer, leaf).await { Ok(caller) => { request.extensions_mut().insert(caller); inner.call(request).await diff --git a/crates/krishiv-connect-server/src/auth/tests.rs b/crates/krishiv-connect-server/src/auth/tests.rs index 1bc97045f..401f268d3 100644 --- a/crates/krishiv-connect-server/src/auth/tests.rs +++ b/crates/krishiv-connect-server/src/auth/tests.rs @@ -65,7 +65,7 @@ fn authenticate( peer: Option, values: &[&str], ) -> Result { - block_on(auth.authenticate(&headers(values), peer)) + block_on(auth.authenticate(&headers(values), peer, None)) } fn block_on(f: F) -> F::Output { diff --git a/crates/krishiv-connect-server/src/host.rs b/crates/krishiv-connect-server/src/host.rs index b51c8a267..2a173b1ce 100644 --- a/crates/krishiv-connect-server/src/host.rs +++ b/crates/krishiv-connect-server/src/host.rs @@ -1,19 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 //! Host hygiene of a server process (design P3-T001 §3.6, threat model -//! T-A1, T-H1, T-H3): which addresses need authentication, the root check -//! and core dumps off. Unix only; elsewhere the checks pass and core dumps +//! T-H1, T-H3): the root check and core dumps off. Which addresses need +//! authentication is `krishiv_session::auth::check_listener`. Unix only; elsewhere the checks pass and core dumps //! are left as they are. -use std::net::IpAddr; - -/// Whether binding `address` without authentication is refused: any address -/// that is not loopback (ADR 0046 §6). `0.0.0.0` and `::` (every interface) -/// are not loopback. -#[must_use] -pub fn needs_auth(address: IpAddr) -> bool { - !address.is_loopback() -} - /// Whether the process runs with effective uid 0. #[must_use] pub fn is_root() -> bool { @@ -69,16 +59,6 @@ pub fn core_dump_limit() -> Option { mod tests { use super::*; - #[test] - fn only_loopback_binds_without_auth() { - for a in ["127.0.0.1", "127.1.2.3", "::1"] { - assert!(!needs_auth(a.parse().unwrap()), "{a}"); - } - for a in ["0.0.0.0", "::", "10.0.0.5", "192.168.1.1", "fe80::1"] { - assert!(needs_auth(a.parse().unwrap()), "{a}"); - } - } - // nextest runs each test in a process of its own, so this changes no // other test's limit. #[test] diff --git a/crates/krishiv-connect-server/src/server.rs b/crates/krishiv-connect-server/src/server.rs index 569cdfef8..43ba5097b 100644 --- a/crates/krishiv-connect-server/src/server.rs +++ b/crates/krishiv-connect-server/src/server.rs @@ -7,6 +7,12 @@ //! RPCs get `UNAVAILABLE`, no new connection is accepted), running queries //! get up to [`ConnectServerConfig::shutdown_timeout`] to finish, then every //! session is closed, which cancels what is left. +//! +//! TLS ([`ConnectServer::with_tls`], design P3-T002 §3.5): each accepted +//! connection finishes its handshake on a task of its own within +//! [`ConnectServerConfig::handshake_timeout`] before tonic sees it; the +//! verified client certificate rides on the connection ([`PeerInfo`]) for +//! the authenticator. use std::future::Future; use std::io; @@ -28,8 +34,11 @@ use krishiv_common::error::KrishivError; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use tokio_rustls::TlsAcceptor; +use tokio_rustls::rustls::ServerConfig; +use tokio_rustls::server::TlsStream; use tonic::transport::Server; -use tonic::transport::server::{Connected, TcpConnectInfo}; +use tonic::transport::server::Connected; use crate::auth::{AuthLayer, ConnectAuth}; use crate::manager::SessionManager; @@ -168,6 +177,7 @@ pub struct ConnectServer { draining: Arc, config: ConnectServerConfig, auth: Arc, + tls: Option>, } impl ConnectServer { @@ -180,9 +190,22 @@ impl ConnectServer { draining: Arc::new(AtomicBool::new(false)), config, auth: Arc::new(ConnectAuth::off()), + tls: None, } } + /// This server speaking TLS with `config` (`krishiv_session::auth::ServerTls`): every + /// connection completes a TLS handshake before its first request. The ALPN list is set to + /// `h2` (gRPC); the certificate resolver is shared, so a reload of the server's TLS + /// material reaches new handshakes here too. + #[must_use] + pub fn with_tls(mut self, config: &ServerConfig) -> Self { + let mut config = config.clone(); + config.alpn_protocols = vec![b"h2".to_vec()]; + self.tls = Some(Arc::new(config)); + self + } + /// This server authenticating every RPC with `auth` (P3-T002). #[must_use] pub fn with_auth(mut self, auth: ConnectAuth) -> Self { @@ -222,6 +245,7 @@ impl ConnectServer { draining, config, auth, + tls, } = self; let service = ConnectService::new(Arc::clone(&manager), Arc::clone(&draining)) .with_auth(Arc::clone(&auth)) @@ -251,7 +275,12 @@ impl ConnectServer { Arc::clone(&manager), config.maintenance_interval, ))); - let incoming = capped(listener, config.max_connections, config.handshake_timeout); + let incoming = capped( + listener, + config.max_connections, + config.handshake_timeout, + tls.map(TlsAcceptor::from), + ); let (stop_tx, stop_rx) = tokio::sync::oneshot::channel::<()>(); let server = router.serve_with_incoming_shutdown(incoming, async { let _ = stop_rx.await; @@ -345,15 +374,19 @@ async fn sweep_loop(manager: Arc, every: Duration) { /// first byte only: a client that sends one byte and stalls keeps its slot /// until the established-connection idle timeout exists (T011a). Accepting runs on a task of its own, aborted when the /// stream is dropped; each new connection waits for its first byte on a -/// task of its own, so one slow client delays no other. +/// task of its own, so one slow client delays no other. With `tls`, the +/// whole TLS handshake must also finish within `handshake`. fn capped( listener: TcpListener, max: usize, handshake: Duration, + tls: Option, ) -> impl futures::Stream> + Send + 'static { let permits = Arc::new(Semaphore::new(max.clamp(1, Semaphore::MAX_PERMITS))); let (tx, rx) = tokio::sync::mpsc::channel::>(64); - let acceptor = AbortOnDrop(tokio::spawn(accept_loop(listener, permits, tx, handshake))); + let acceptor = AbortOnDrop(tokio::spawn(accept_loop( + listener, permits, tx, handshake, tls, + ))); futures::stream::unfold((rx, acceptor), |(mut rx, acceptor)| async move { rx.recv().await.map(|item| (item, (rx, acceptor))) }) @@ -364,15 +397,16 @@ async fn accept_loop( permits: Arc, tx: tokio::sync::mpsc::Sender>, handshake: Duration, + tls: Option, ) { let mut backoff = Duration::from_millis(10); loop { let Ok(permit) = Arc::clone(&permits).acquire_owned().await else { return; }; - let stream = loop { + let (stream, remote) = loop { match listener.accept().await { - Ok((stream, _)) => break stream, + Ok(accepted) => break accepted, // A failed accept (the peer reset before it was taken, too // many open files) does not stop the server; back off, as // every retry fails while file descriptors are out. @@ -386,26 +420,53 @@ async fn accept_loop( backoff = Duration::from_millis(10); let _ = stream.set_nodelay(true); let sender = tx.clone(); + let tls = tls.clone(); tokio::spawn(async move { - let mut first = [0_u8; 1]; + let opened = async { + let mut first = [0_u8; 1]; + match stream.peek(&mut first).await { + Ok(n) if n > 0 => {} + // Closed or failed. + _ => return None, + } + Some(match tls { + None => (Conn::Plain(stream), None), + Some(acceptor) => match acceptor.accept(stream).await { + Ok(tls) => { + let leaf = verified_leaf(&tls); + (Conn::Tls(Box::new(tls)), leaf) + } + Err(e) => { + tracing::debug!( + target: "krishiv::connect", + peer = %remote, + error = %e, + "TLS handshake failed" + ); + return None; + } + }, + }) + }; // A zero deadline means none (as `0s` for the session idle // timeout). - let peeked = if handshake.is_zero() { - Ok(stream.peek(&mut first).await) + let opened = if handshake.is_zero() { + Ok(opened.await) } else { - tokio::time::timeout(handshake, stream.peek(&mut first)).await + tokio::time::timeout(handshake, opened).await }; - match peeked { - Ok(Ok(n)) if n > 0 => { - let _ = sender - .send(Ok(CappedStream { - inner: stream, - _permit: permit, - })) - .await; - } - // Silent, closed or failed: drop it and its permit. - _ => {} + // Silent, closed, failed or a failed handshake: drop it and its permit. + if let Ok(Some((inner, leaf))) = opened { + let _ = sender + .send(Ok(CappedStream { + inner, + info: PeerInfo { + remote: Some(remote), + leaf, + }, + _permit: permit, + })) + .await; } }); if tx.is_closed() { @@ -414,28 +475,71 @@ async fn accept_loop( } } +/// The verified client leaf certificate of a TLS connection (rustls checked +/// its chain against the client CA at the handshake), if the client sent one. +fn verified_leaf(tls: &TlsStream) -> Option> { + tls.get_ref() + .1 + .peer_certificates() + .and_then(<[_]>::first) + .map(|c| Arc::from(c.as_ref())) +} + +/// What a connection tells the authenticator: the peer's address (the +/// throttle's key) and, over TLS, the client certificate rustls verified. +#[derive(Debug, Clone, Default)] +pub struct PeerInfo { + /// The peer's address. + pub remote: Option, + /// The verified client leaf certificate (DER), if the client sent one. + pub leaf: Option>, +} + +/// A plain or TLS connection. +#[derive(Debug)] +enum Conn { + Plain(TcpStream), + Tls(Box>), +} + /// A connection that holds one of the listener's permits until it closes. #[derive(Debug)] pub struct CappedStream { - inner: TcpStream, + inner: Conn, + info: PeerInfo, _permit: OwnedSemaphorePermit, } impl Connected for CappedStream { - type ConnectInfo = TcpConnectInfo; + type ConnectInfo = PeerInfo; - fn connect_info(&self) -> TcpConnectInfo { - self.inner.connect_info() + fn connect_info(&self) -> PeerInfo { + self.info.clone() } } +macro_rules! conn { + ($self:expr, $s:ident => $e:expr) => { + match &mut $self.inner { + Conn::Plain($s) => { + let $s = Pin::new($s); + $e + } + Conn::Tls($s) => { + let $s = Pin::new($s.as_mut()); + $e + } + } + }; +} + impl AsyncRead for CappedStream { fn poll_read( mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { - Pin::new(&mut self.inner).poll_read(cx, buf) + conn!(self, s => s.poll_read(cx, buf)) } } @@ -445,15 +549,15 @@ impl AsyncWrite for CappedStream { cx: &mut Context<'_>, buf: &[u8], ) -> Poll> { - Pin::new(&mut self.inner).poll_write(cx, buf) + conn!(self, s => s.poll_write(cx, buf)) } fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_flush(cx) + conn!(self, s => s.poll_flush(cx)) } fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_shutdown(cx) + conn!(self, s => s.poll_shutdown(cx)) } fn poll_write_vectored( @@ -461,10 +565,13 @@ impl AsyncWrite for CappedStream { cx: &mut Context<'_>, bufs: &[io::IoSlice<'_>], ) -> Poll> { - Pin::new(&mut self.inner).poll_write_vectored(cx, bufs) + conn!(self, s => s.poll_write_vectored(cx, bufs)) } fn is_write_vectored(&self) -> bool { - self.inner.is_write_vectored() + match &self.inner { + Conn::Plain(s) => s.is_write_vectored(), + Conn::Tls(s) => s.is_write_vectored(), + } } } diff --git a/crates/krishiv-connect-server/tests/tls.rs b/crates/krishiv-connect-server/tests/tls.rs new file mode 100644 index 000000000..2ac2e8d91 --- /dev/null +++ b/crates/krishiv-connect-server/tests/tls.rs @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: Apache-2.0 +//! The Connect listener over TLS (P3-T002 design §3.4, §3.5): `ConnectServer::with_tls` with the +//! server's `ServerTls`; a bearer token over TLS, a client certificate that authenticates by +//! itself with the role its name is listed under, and a client in clear refused. Fixtures: +//! `crates/krishiv-session/tests/fixtures/tls/README.md`. +#![cfg(unix)] +#![expect( + clippy::expect_used, + reason = "integration-test helpers are outside #[test] fns" +)] + +use std::net::SocketAddr; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use hyper_util::rt::TokioIo; +use krishiv_common::config::Config; +use krishiv_connect::proto::v4_3 as pb; +use krishiv_connect_server::{ + ConnectAuth, ConnectServer, ConnectServerConfig, SessionLimits, SessionManager, role_sessions, +}; +use krishiv_session::Session; +use krishiv_session::auth::{AuthConfig, ServerAuth, ServerTls, TlsConfig}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::oneshot; +use tokio_rustls::TlsConnector; +use tokio_rustls::rustls::pki_types::pem::PemObject as _; +use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; +use tokio_rustls::rustls::{ClientConfig, RootCertStore}; +use tonic::Code; +use tonic::transport::{Channel, Endpoint}; + +const SESSION: &str = "8f0e4c55-5ad0-4d1e-9a3c-3a1b0b2c7d10"; +const VIEWER: &str = "viewer-token-0123456789abcdefghijklmnopqrstuv"; + +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join(format!("../krishiv-session/tests/fixtures/tls/{name}")) +} + +/// `src` copied into `dir` with mode 0600 (git keeps no modes). +fn private_copy(dir: &Path, src: &Path, name: &str) -> String { + let path = dir.join(name); + std::fs::copy(src, &path).expect("copy"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("chmod"); + path.to_str().expect("utf-8 path").to_owned() +} + +/// A TLS server: a viewer token (`carol`), client certificates `optional` with the principal +/// from the CN, `etl-job` an operator. +async fn start() -> (SocketAddr, oneshot::Sender<()>, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("tempdir"); + let token = dir.path().join("token"); + std::fs::write(&token, VIEWER).expect("write"); + let secret = format!("file:{}", private_copy(dir.path(), &token, "v")); + let key = private_copy(dir.path(), &fixture("server.key"), "server.key"); + let cert = fixture("server.pem"); + let ca = format!("file:{}", fixture("ca.pem").display()); + let settings = [ + ("krishiv.auth.tokens.v.secret", secret.as_str()), + ("krishiv.auth.tokens.v.principal", "carol"), + ("krishiv.auth.tokens.v.role", "viewer"), + ("krishiv.auth.clientCertificates", "optional"), + ("krishiv.auth.clientCa", ca.as_str()), + ("krishiv.auth.mtls.principalFrom", "cn"), + ("krishiv.auth.roles.operator", "etl-job"), + ("krishiv.server.tls.certFile", cert.to_str().expect("utf-8")), + ("krishiv.server.tls.keyFile", key.as_str()), + ]; + let auth_config = AuthConfig::from_settings(settings).expect("auth config"); + let tls_config = TlsConfig::from_settings(settings).expect("tls config"); + let tls = ServerTls::new(&tls_config, &auth_config.mtls) + .expect("server tls") + .expect("tls on"); + let auth = Arc::new(ServerAuth::new(&auth_config).expect("auth")); + let factory = role_sessions(Session::new().expect("session"), &Config::new()).expect("factory"); + let manager = Arc::new(SessionManager::new(factory, SessionLimits::default())); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + let server = ConnectServer::new(manager, ConnectServerConfig::default()) + .with_auth(ConnectAuth::new(auth)) + .with_tls(&tls.server_config()); + let (stop, stopped) = oneshot::channel::<()>(); + tokio::spawn(server.serve(listener, async { + let _ = stopped.await; + })); + (addr, stop, dir) +} + +/// A channel over TLS to `addr`, trusting the test CA and presenting the fixture `identity`. +async fn tls_channel(addr: SocketAddr, identity: Option<&str>) -> Channel { + let mut roots = RootCertStore::empty(); + roots + .add(CertificateDer::from_pem_file(fixture("ca.pem")).expect("ca")) + .expect("add ca"); + let provider = Arc::new(tokio_rustls::rustls::crypto::aws_lc_rs::default_provider()); + let builder = ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .expect("versions") + .with_root_certificates(roots); + let mut config = match identity { + None => builder.with_no_client_auth(), + Some(name) => builder + .with_client_auth_cert( + vec![CertificateDer::from_pem_file(fixture(&format!("{name}.pem"))).expect("cert")], + PrivateKeyDer::from_pem_file(fixture(&format!("{name}.key"))).expect("key"), + ) + .expect("client auth"), + }; + config.alpn_protocols = vec![b"h2".to_vec()]; + let connector = TlsConnector::from(Arc::new(config)); + Endpoint::from_static("http://localhost") + .connect_with_connector(tower::service_fn(move |_| { + let connector = connector.clone(); + async move { + let tcp = TcpStream::connect(addr).await?; + let name = ServerName::try_from("localhost").map_err(std::io::Error::other)?; + let tls = connector.connect(name, tcp).await?; + Ok::<_, std::io::Error>(TokioIo::new(tls)) + } + })) + .await + .expect("connect") +} + +async fn release(channel: &Channel, token: Option<&str>) -> Result<(), tonic::Status> { + let mut grpc = tonic::client::Grpc::new(channel.clone()); + grpc.ready() + .await + .map_err(|e| tonic::Status::unavailable(e.to_string()))?; + let mut request = tonic::Request::new(pb::ReleaseSessionRequest { + session_id: SESSION.to_owned(), + user_context: Some(pb::UserContext::default()), + ..Default::default() + }); + if let Some(t) = token { + request.metadata_mut().insert( + "authorization", + format!("Bearer {t}").parse().expect("ascii"), + ); + } + grpc.unary( + request, + "/spark.connect.SparkConnectService/ReleaseSession" + .parse() + .expect("path"), + tonic_prost::ProstCodec::::default(), + ) + .await + .map(|_| ()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_token_and_a_certificate_authenticate_over_tls() { + let (addr, _stop, _dir) = start().await; + let plain_tls = tls_channel(addr, None).await; + release(&plain_tls, Some(VIEWER)) + .await + .expect("token over TLS"); + let s = release(&plain_tls, None).await.expect_err("no credential"); + assert_eq!(s.code(), Code::Unauthenticated); + // The certificate alone: `etl-job` holds the operator role. + let with_cert = tls_channel(addr, Some("client")).await; + release(&with_cert, None).await.expect("certificate"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_client_in_clear_gets_no_answer() { + let (addr, _stop, _dir) = start().await; + // h2 in clear on the TLS port: the handshake fails, so no RPC gets through. + let failed = match Channel::from_shared(format!("http://{addr}")) + .expect("uri") + .connect() + .await + { + Err(_) => true, + Ok(channel) => release(&channel, Some(VIEWER)).await.is_err(), + }; + assert!(failed, "an RPC in clear reached a TLS listener"); +} diff --git a/crates/krishiv-server/CLAUDE.md b/crates/krishiv-server/CLAUDE.md index 46a19f236..4de3e8e3e 100644 --- a/crates/krishiv-server/CLAUDE.md +++ b/crates/krishiv-server/CLAUDE.md @@ -9,13 +9,14 @@ Admin and UI API server (REST, OpenAPI), serves the web UI. Today: `/api/v1/vers ## Key files Paths are relative to this crate. - `src/lib.rs` `router()`, `serve` and `run_blocking` -- `src/guard.rs` (P3-T002) `Guard`, the one check every guarded admin-listener route asks: `GuardRequest` (presented token, malformed flag, peer, `GuardSurface`, `Access`: `Authenticated`, `Metrics` = `Monitor` on `System("metrics")`, `Monitor` on `System("system")`, `Operate` on `System("queries")`), `Refusal` (401 / 503 / 403 / 500 fail closed), `TokenGuard` (the start-up token of a server without authentication), `PeerAddr` (the peer `serve_capped` records as `ConnectInfo` for the throttle) +- `src/guard.rs` (P3-T002) `Guard`, the one check every guarded admin-listener route asks: `GuardRequest` (presented token, malformed flag, peer, `GuardSurface`, `Access`: `Authenticated`, `Metrics` = `Monitor` on `System("metrics")`, `Monitor` on `System("system")`, `Operate` on `System("queries")`), `Refusal` (401 / 503 / 403 / 500 fail closed), `TokenGuard` (the start-up token of a server without authentication), `PeerAddr` (the peer `serve_capped` records as `ConnectInfo`: the address for the throttle and, over TLS, the verified client leaf; `peer_of` / `peer_leaf_of`, passed on as `GuardRequest::with_peer_leaf` to `Credentials::peer_leaf`) - `src/session_guard.rs` (feature `session`) `SessionGuard`: the server's `ServerAuth`, then the ADR 0046 matrix through `RolePolicy` (no role table of its own); a denial writes one `krishiv::audit` record - `src/internal.rs` the UI backend (P2-T133 part a): `router_with_ui(source, token)` and `router_with_ui_guard(source, guard)` (GET needs `Access::Monitor`, anything else `Access::Operate`: operator), the `UiSource` trait (system-table rows as JSON, cancel), `/api/internal/{health,queries,queries/{id},queries/{id}/cancel,history,compare,resources,events}`; every route needs the token (`Authorization: Bearer` for any method; the `krishiv_ui_token` cookie or `?token=` only for GET), query text is shown with its literals masked, at most 16 event streams share one snapshot a period, every response of that router carries the CSP / `frame-ancestors 'none'` headers, columns are allowlisted per endpoint, `events` is SSE at one event a second - `src/admin.rs` `krishiv server`'s admin endpoints (design P3-T001 §3.5): `health_routes` (`/healthz` open, `/readyz` 200/503 from a ready flag, fixed bodies; never guarded), `with_limits` (request timeout 30 s, SSE `/events` exempt; body limit 2 MiB), `serve_capped` (connection cap, graceful shutdown); `tests/admin.rs` - `src/session_source.rs` `SessionSource` (UI over a runtime) and `EmbeddedUi` (serves it from a host process, token + stop on drop) - `src/metrics.rs` `router_with_metrics`, `router_with_metrics_auth` (with a token, `/metrics` needs `Authorization: Bearer `, compared in constant time; a refusal is a JSON 401 and takes no snapshot; an empty token refuses everyone; a disabled `/metrics` stays a 404 for every caller) and `render` (feature `metrics-prometheus` only). `metrics_route` is the route alone, `metrics_route_guarded` the same behind a `Guard` (`Access::Metrics`, every role), merged into `krishiv server`'s admin router - `examples/console_capture.rs` runs TPC-H queries through an embedded runtime and serves the UI over it; the website's console captures (`site/capture-console.sh` in the docs repo) use it +- `src/tls.rs` (feature `tls`, P3-T002 design §3.5) `serve_capped_tls`: the admin listener over rustls (`tokio-rustls`), ALPN `http/1.1`, each handshake on a task of its own within `HANDSHAKE_TIMEOUT` (10 s) holding a connection slot, the verified client leaf on `TlsCappedStream::peer_leaf`; `tests/tls.rs` (features `session` + `metrics-prometheus` + `tls`) - `tests/serve.rs` server tests - `tests/auth.rs` (features `session` + `metrics-prometheus`) the admin listener with configured tokens: per-role access, 401 / throttle 503, open probes - `tests/metrics.rs` `/metrics` exposition tests (feature `metrics-prometheus` only) diff --git a/crates/krishiv-server/Cargo.toml b/crates/krishiv-server/Cargo.toml index 654021800..25f0c4e74 100644 --- a/crates/krishiv-server/Cargo.toml +++ b/crates/krishiv-server/Cargo.toml @@ -20,6 +20,7 @@ futures = { workspace = true, features = ["alloc"] } getrandom.workspace = true serde_json = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["rt-multi-thread", "net", "time", "sync"] } +tokio-rustls = { workspace = true, optional = true } tracing.workspace = true [features] @@ -29,6 +30,8 @@ tracing.workspace = true metrics-prometheus = ["dep:krishiv-exec"] # `SessionSource`: the UI backend over an engine runtime (embedded mode), reading the system tables. session = ["dep:krishiv-session", "dep:krishiv-common", "dep:arrow-array", "dep:arrow-schema"] +# The admin listener over TLS (`tls::serve_capped_tls`, P3-T002 design §3.5). +tls = ["dep:tokio-rustls", "krishiv-session?/tls"] [dev-dependencies] krishiv-session = { path = "../krishiv-session" } diff --git a/crates/krishiv-server/src/guard.rs b/crates/krishiv-server/src/guard.rs index f47560fc6..3e27284d1 100644 --- a/crates/krishiv-server/src/guard.rs +++ b/crates/krishiv-server/src/guard.rs @@ -50,7 +50,7 @@ pub enum GuardSurface { } /// What a surface extracted from one request. Never logged: `Debug` hides the token. -#[derive(Clone, Copy)] +#[derive(Clone)] #[non_exhaustive] pub struct GuardRequest<'a> { /// The presented token, if the surface found one. @@ -59,6 +59,8 @@ pub struct GuardRequest<'a> { pub malformed: bool, /// The peer's address, when the listener records it ([`PeerAddr`]). pub peer: Option, + /// The client certificate rustls verified at the TLS handshake, if any. + pub peer_leaf: Option>, /// The surface. pub surface: GuardSurface, /// What the route needs. @@ -73,6 +75,7 @@ impl<'a> GuardRequest<'a> { token: None, malformed: false, peer: None, + peer_leaf: None, surface, access, } @@ -98,6 +101,13 @@ impl<'a> GuardRequest<'a> { self.peer = peer; self } + + /// The same request with the connection's verified client certificate. + #[must_use] + pub fn with_peer_leaf(mut self, leaf: Option>) -> Self { + self.peer_leaf = leaf; + self + } } impl fmt::Debug for GuardRequest<'_> { @@ -182,20 +192,45 @@ pub(crate) fn same_secret(a: &[u8], b: &[u8]) -> bool { std::hint::black_box(diff) == 0 } -/// The peer address of an admin connection, recorded by [`crate::admin::serve_capped`] as -/// `ConnectInfo` for the failure throttle. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PeerAddr(pub SocketAddr); +/// The peer of an admin connection, recorded by [`crate::admin::serve_capped`] as +/// `ConnectInfo`: its address (the failure throttle's key) and, over TLS, the client +/// certificate rustls verified. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PeerAddr { + /// The peer's address. + pub addr: SocketAddr, + /// The verified client leaf certificate (DER), if the connection is TLS and the client + /// sent one. + pub leaf: Option>, +} + +impl PeerAddr { + /// A peer at `addr` with no client certificate. + #[must_use] + pub fn new(addr: SocketAddr) -> Self { + Self { addr, leaf: None } + } +} impl Connected> for PeerAddr { fn connect_info(stream: IncomingStream<'_, crate::admin::CappedListener>) -> Self { - Self(*stream.remote_addr()) + Self::new(*stream.remote_addr()) + } +} + +#[cfg(feature = "tls")] +impl Connected> for PeerAddr { + fn connect_info(stream: IncomingStream<'_, crate::tls::TlsCappedListener>) -> Self { + Self { + addr: *stream.remote_addr(), + leaf: stream.io().peer_leaf(), + } } } impl Connected for PeerAddr { fn connect_info(addr: SocketAddr) -> Self { - Self(addr) + Self::new(addr) } } @@ -204,7 +239,15 @@ impl Connected for PeerAddr { pub fn peer_of(extensions: &axum::http::Extensions) -> Option { extensions .get::>() - .map(|c| c.0.0) + .map(|c| c.0.addr) +} + +/// The verified client certificate recorded on `extensions`, if the connection carried one. +#[must_use] +pub fn peer_leaf_of(extensions: &axum::http::Extensions) -> Option> { + extensions + .get::>() + .and_then(|c| c.0.leaf.clone()) } #[cfg(test)] diff --git a/crates/krishiv-server/src/internal.rs b/crates/krishiv-server/src/internal.rs index b1f89a1b7..e972a5d98 100644 --- a/crates/krishiv-server/src/internal.rs +++ b/crates/krishiv-server/src/internal.rs @@ -25,7 +25,9 @@ use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use serde_json::{Map, Value, json}; -use crate::guard::{Access, Guard, GuardRequest, GuardSurface, Refusal, TokenGuard, peer_of}; +use crate::guard::{ + Access, Guard, GuardRequest, GuardSurface, Refusal, TokenGuard, peer_leaf_of, peer_of, +}; /// One system-table row as JSON (column name to value). pub type Row = Map; @@ -255,7 +257,8 @@ async fn require_token( let request = GuardRequest::new(GuardSurface::Ui, access) .with_token(token.as_deref()) .with_malformed(malformed) - .with_peer(peer_of(req.extensions())); + .with_peer(peer_of(req.extensions())) + .with_peer_leaf(peer_leaf_of(req.extensions())); match s.guard.check(request).await { Ok(()) => next.run(req).await, Err(Refusal::Unauthenticated) => { diff --git a/crates/krishiv-server/src/lib.rs b/crates/krishiv-server/src/lib.rs index 24895fe4f..996d87fcc 100644 --- a/crates/krishiv-server/src/lib.rs +++ b/crates/krishiv-server/src/lib.rs @@ -27,6 +27,8 @@ pub mod metrics; pub mod admin; pub mod guard; pub mod internal; +#[cfg(feature = "tls")] +pub mod tls; #[cfg(feature = "session")] pub mod session_guard; diff --git a/crates/krishiv-server/src/metrics.rs b/crates/krishiv-server/src/metrics.rs index 932b24548..081e9d14e 100644 --- a/crates/krishiv-server/src/metrics.rs +++ b/crates/krishiv-server/src/metrics.rs @@ -21,7 +21,9 @@ use krishiv_exec::{MetricsSnapshot, prometheus}; #[cfg(test)] use crate::guard::same_secret; -use crate::guard::{Access, Guard, GuardRequest, GuardSurface, Refusal, TokenGuard, peer_of}; +use crate::guard::{ + Access, Guard, GuardRequest, GuardSurface, Refusal, TokenGuard, peer_leaf_of, peer_of, +}; /// Provides the snapshot a scrape renders: called once per `GET /// /metrics`, on the request task. Must be cheap: [`MetricsSnapshot`] is @@ -155,7 +157,8 @@ pub fn metrics_route_guarded( let check = GuardRequest::new(GuardSurface::Metrics, Access::Metrics) .with_token(token) .with_malformed(malformed) - .with_peer(peer_of(request.extensions())); + .with_peer(peer_of(request.extensions())) + .with_peer_leaf(peer_leaf_of(request.extensions())); if let Err(refusal) = guard.check(check).await { return refused(refusal); } diff --git a/crates/krishiv-server/src/session_guard.rs b/crates/krishiv-server/src/session_guard.rs index 37cb8cbef..8cc410e5c 100644 --- a/crates/krishiv-server/src/session_guard.rs +++ b/crates/krishiv-server/src/session_guard.rs @@ -69,6 +69,7 @@ impl Guard for SessionGuard { }; let mut credentials = Credentials::new(peer, surface); credentials.malformed = request.malformed; + credentials.peer_leaf = request.peer_leaf.as_deref(); if let Some(token) = request.token { credentials = credentials.with_bearer(token); } diff --git a/crates/krishiv-server/src/tls.rs b/crates/krishiv-server/src/tls.rs new file mode 100644 index 000000000..e9a0aa004 --- /dev/null +++ b/crates/krishiv-server/src/tls.rs @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: Apache-2.0 +//! The admin listener over TLS (P3-T002 design §3.5; feature `tls`): [`serve_capped_tls`] is +//! [`crate::admin::serve_capped`] with each connection's TLS handshake run on a task of its own, +//! within [`HANDSHAKE_TIMEOUT`], so a slow or silent client delays no other and holds its slot for +//! at most that long. The client certificate rustls verified rides on the connection +//! ([`crate::guard::PeerAddr`]) for the authenticator. + +use std::future::Future; +use std::io; +use std::net::SocketAddr; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::time::Duration; + +use axum::Router; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc}; +use tokio::task::JoinHandle; +use tokio_rustls::TlsAcceptor; +use tokio_rustls::rustls::ServerConfig; +use tokio_rustls::server::TlsStream; + +/// The longest a connection may take from accept to a finished TLS handshake. +pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); + +/// Serves `app` on `listener` over TLS with `config` (the ALPN list is set to `http/1.1`: the +/// admin server speaks HTTP/1.1 only; the certificate resolver is shared, so a reload of the +/// server's TLS material reaches new handshakes here too), at most `max_connections` open at +/// once (a connection in its handshake holds a slot), until `shutdown` resolves. +/// +/// # Errors +/// An I/O error of the server. +pub async fn serve_capped_tls( + listener: TcpListener, + app: Router, + max_connections: usize, + config: &ServerConfig, + shutdown: impl Future + Send + 'static, +) -> io::Result<()> { + let mut config = config.clone(); + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + let local = listener.local_addr()?; + let permits = Arc::new(Semaphore::new( + max_connections.clamp(1, Semaphore::MAX_PERMITS), + )); + let (tx, rx) = mpsc::channel(64); + let acceptor = tokio::spawn(accept_loop( + listener, + TlsAcceptor::from(Arc::new(config)), + permits, + tx, + )); + let listener = TlsCappedListener { + rx, + local, + _acceptor: AbortOnDrop(acceptor), + }; + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown) + .await +} + +async fn accept_loop( + listener: TcpListener, + acceptor: TlsAcceptor, + permits: Arc, + tx: mpsc::Sender<(TlsCappedStream, SocketAddr)>, +) { + let mut backoff = Duration::from_millis(10); + loop { + let Ok(permit) = Arc::clone(&permits).acquire_owned().await else { + return; + }; + let (stream, addr) = match listener.accept().await { + Ok(accepted) => accepted, + // Out of file descriptors every retry fails: log and back off. + Err(e) => { + tracing::warn!(target: "krishiv::server", error = %e, "admin accept failed"); + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(Duration::from_secs(1)); + continue; + } + }; + backoff = Duration::from_millis(10); + let (acceptor, sender) = (acceptor.clone(), tx.clone()); + tokio::spawn(async move { + match tokio::time::timeout(HANDSHAKE_TIMEOUT, acceptor.accept(stream)).await { + Ok(Ok(tls)) => { + let leaf = tls + .get_ref() + .1 + .peer_certificates() + .and_then(<[_]>::first) + .map(|c| Arc::from(c.as_ref())); + let stream = TlsCappedStream { + inner: Box::new(tls), + leaf, + _permit: permit, + }; + let _ = sender.send((stream, addr)).await; + } + Ok(Err(e)) => { + tracing::debug!( + target: "krishiv::server", + peer = %addr, + error = %e, + "admin TLS handshake failed" + ); + } + // Too slow: drop it and its permit. + Err(_) => {} + } + }); + if tx.is_closed() { + return; + } + } +} + +/// Aborts its task when dropped, so a stopped server leaves no accept loop behind. +#[derive(Debug)] +struct AbortOnDrop(JoinHandle<()>); + +impl Drop for AbortOnDrop { + fn drop(&mut self) { + self.0.abort(); + } +} + +/// The TLS admin listener: the connections whose handshake finished. +#[derive(Debug)] +pub struct TlsCappedListener { + rx: mpsc::Receiver<(TlsCappedStream, SocketAddr)>, + local: SocketAddr, + _acceptor: AbortOnDrop, +} + +impl axum::serve::Listener for TlsCappedListener { + type Io = TlsCappedStream; + type Addr = SocketAddr; + + async fn accept(&mut self) -> (TlsCappedStream, SocketAddr) { + match self.rx.recv().await { + Some(accepted) => accepted, + // The accept loop ended (it never does while the listener lives): wait as a + // closed listener would. + None => std::future::pending().await, + } + } + + fn local_addr(&self) -> io::Result { + Ok(self.local) + } +} + +/// A TLS admin connection holding one of the listener's permits. +#[derive(Debug)] +pub struct TlsCappedStream { + inner: Box>, + leaf: Option>, + _permit: OwnedSemaphorePermit, +} + +impl TlsCappedStream { + /// The client's leaf certificate (DER) rustls verified at the handshake, if it sent one. + #[must_use] + pub fn peer_leaf(&self) -> Option> { + self.leaf.clone() + } +} + +impl AsyncRead for TlsCappedStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(self.inner.as_mut()).poll_read(cx, buf) + } +} + +impl AsyncWrite for TlsCappedStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(self.inner.as_mut()).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(self.inner.as_mut()).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(self.inner.as_mut()).poll_shutdown(cx) + } +} diff --git a/crates/krishiv-server/tests/tls.rs b/crates/krishiv-server/tests/tls.rs new file mode 100644 index 000000000..d7f27963f --- /dev/null +++ b/crates/krishiv-server/tests/tls.rs @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: Apache-2.0 +//! The admin listener over TLS (P3-T002 design §3.4, §3.5): `serve_capped_tls` with the server's +//! `ServerTls`, a bearer token over TLS, plain HTTP refused, and a client certificate that +//! authenticates by itself with the role its name is listed under. Fixtures: +//! `crates/krishiv-session/tests/fixtures/tls/README.md`. +#![cfg(all( + unix, + feature = "session", + feature = "metrics-prometheus", + feature = "tls" +))] +#![expect( + clippy::expect_used, + reason = "integration-test helpers are outside #[test] fns" +)] + +use std::net::SocketAddr; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +use krishiv_exec::MetricsSnapshot; +use krishiv_server::admin::{HttpLimits, health_routes, with_limits}; +use krishiv_server::guard::Guard; +use krishiv_server::internal::{Row, SystemTable, UiFuture, UiSource, router_with_ui_guard}; +use krishiv_server::metrics::metrics_route_guarded; +use krishiv_server::session_guard::SessionGuard; +use krishiv_server::tls::serve_capped_tls; +use krishiv_session::auth::{AuthConfig, ServerAuth, ServerTls, TlsConfig}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio_rustls::TlsConnector; +use tokio_rustls::rustls::pki_types::pem::PemObject as _; +use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; +use tokio_rustls::rustls::{ClientConfig, RootCertStore}; + +const VIEWER: &str = "viewer-token-0123456789abcdef0123456789"; + +struct Empty; + +impl UiSource for Empty { + fn rows(&self, _: SystemTable) -> UiFuture<'_, Vec> { + Box::pin(async { Ok(Vec::new()) }) + } + fn cancel<'a>(&'a self, _: &'a str) -> UiFuture<'a, bool> { + Box::pin(async { Ok(false) }) + } +} + +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join(format!("../krishiv-session/tests/fixtures/tls/{name}")) +} + +/// `src` copied into `dir` with mode 0600 (git keeps no modes), as a path string. +fn private_copy(dir: &Path, src: &Path, name: &str) -> String { + let path = dir.join(name); + std::fs::copy(src, &path).expect("copy"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("chmod"); + path.to_str().expect("utf-8 path").to_owned() +} + +/// The admin router of `krishiv server` over TLS: a viewer token, client certificates +/// `optional` with the principal from the CN, `etl-job` an operator. +async fn start() -> (SocketAddr, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("tempdir"); + let secret = dir.path().join("v"); + std::fs::write(&secret, VIEWER).expect("write secret"); + let secret = private_copy(dir.path(), &secret, "viewer"); + let cert = fixture("server.pem"); + let key = private_copy(dir.path(), &fixture("server.key"), "server.key"); + let ca = format!("file:{}", fixture("ca.pem").display()); + let secret_ref = format!("file:{secret}"); + let settings = [ + ("krishiv.auth.tokens.prom.secret", secret_ref.as_str()), + ("krishiv.auth.tokens.prom.principal", "prometheus"), + ("krishiv.auth.tokens.prom.role", "viewer"), + ("krishiv.auth.clientCertificates", "optional"), + ("krishiv.auth.clientCa", ca.as_str()), + ("krishiv.auth.mtls.principalFrom", "cn"), + ("krishiv.auth.roles.operator", "etl-job"), + ("krishiv.server.tls.certFile", cert.to_str().expect("utf-8")), + ("krishiv.server.tls.keyFile", key.as_str()), + ]; + let auth_config = AuthConfig::from_settings(settings).expect("auth config"); + let tls_config = TlsConfig::from_settings(settings).expect("tls config"); + let tls = ServerTls::new(&tls_config, &auth_config.mtls) + .expect("server tls") + .expect("tls on"); + let auth = ServerAuth::new(&auth_config).expect("server auth"); + let guard: Arc = Arc::new(SessionGuard::new(Arc::new(auth))); + let metrics = metrics_route_guarded( + true, + Arc::new(MetricsSnapshot::default), + Some(Arc::clone(&guard)), + ); + let app = router_with_ui_guard(Arc::new(Empty), guard) + .merge(health_routes(Arc::new(AtomicBool::new(true)))) + .merge(metrics); + let limits = HttpLimits::default(); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + let config = tls.server_config(); + tokio::spawn(async move { + serve_capped_tls( + listener, + with_limits(app, limits), + limits.max_connections, + &config, + std::future::pending(), + ) + .await + }); + (addr, dir) +} + +/// A client trusting the test CA, presenting the fixture `identity` if given. +fn client(identity: Option<&str>) -> TlsConnector { + let mut roots = RootCertStore::empty(); + roots + .add(CertificateDer::from_pem_file(fixture("ca.pem")).expect("ca")) + .expect("add ca"); + let provider = Arc::new(tokio_rustls::rustls::crypto::aws_lc_rs::default_provider()); + let builder = ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .expect("versions") + .with_root_certificates(roots); + let config = match identity { + None => builder.with_no_client_auth(), + Some(name) => { + let chain = + vec![CertificateDer::from_pem_file(fixture(&format!("{name}.pem"))).expect("cert")]; + let key = PrivateKeyDer::from_pem_file(fixture(&format!("{name}.key"))).expect("key"); + builder + .with_client_auth_cert(chain, key) + .expect("client auth") + } + }; + TlsConnector::from(Arc::new(config)) +} + +async fn exchange( + mut s: S, + path: &str, + token: Option<&str>, +) -> String { + let auth = token.map_or(String::new(), |t| format!("Authorization: Bearer {t}\r\n")); + let request = format!("GET {path} HTTP/1.1\r\nHost: x\r\n{auth}Connection: close\r\n\r\n"); + s.write_all(request.as_bytes()).await.expect("write"); + let mut out = Vec::new(); + // A server that closes without `close_notify` ends the read with an error; keep what came. + let _ = s.read_to_end(&mut out).await; + String::from_utf8_lossy(&out).into_owned() +} + +/// The response to `GET path` over TLS, presenting `identity` and `token`. +async fn get(addr: SocketAddr, path: &str, identity: Option<&str>, token: Option<&str>) -> String { + let tcp = TcpStream::connect(addr).await.expect("connect"); + let name = ServerName::try_from("localhost").expect("name"); + let tls = client(identity) + .connect(name, tcp) + .await + .expect("handshake"); + exchange(tls, path, token).await +} + +fn status(response: &str) -> &str { + response.get(9..12).unwrap_or("") +} + +#[tokio::test] +async fn a_bearer_token_works_over_tls_and_plain_http_gets_nothing() { + let (addr, _dir) = start().await; + assert_eq!(status(&get(addr, "/healthz", None, None).await), "200"); + assert_eq!( + status(&get(addr, "/metrics", None, Some(VIEWER)).await), + "200" + ); + assert_eq!(status(&get(addr, "/metrics", None, None).await), "401"); + // HTTP in clear on the TLS port: the handshake fails and no HTTP answer comes back. + let plain = TcpStream::connect(addr).await.expect("connect"); + let answer = exchange(plain, "/healthz", None).await; + assert!(!answer.starts_with("HTTP/"), "{answer:?}"); +} + +#[tokio::test] +async fn a_client_certificate_authenticates_with_its_listed_role() { + let (addr, _dir) = start().await; + let queries = "/api/internal/queries"; + // `etl-job` (the CN) is an operator: it monitors other principals' queries, no token. + assert_eq!( + status(&get(addr, queries, Some("client"), None).await), + "200" + ); + // The viewer token alone does not reach them (403), and nothing at all is 401. + assert_eq!(status(&get(addr, queries, None, Some(VIEWER)).await), "403"); + assert_eq!(status(&get(addr, queries, None, None).await), "401"); + // `reporting` holds no role: authenticated, but `/metrics` needs one. + assert_eq!( + status(&get(addr, "/metrics", Some("nosan"), None).await), + "403" + ); +} From 2187e1c25b42d75d6bd3f89389aa1595a5b921da Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Sat, 14 Nov 2026 00:30:29 +0000 Subject: [PATCH 31/47] common: an Azure container@account URL is a path, not a credential, so allowed abfss/wasbs paths reach the reader Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-common/src/secret/classify.rs | 20 +++++++++++++++++++- crates/krishiv-common/src/secret/tests.rs | 7 +++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/krishiv-common/src/secret/classify.rs b/crates/krishiv-common/src/secret/classify.rs index f7f8c27d6..c4c8d33ab 100644 --- a/crates/krishiv-common/src/secret/classify.rs +++ b/crates/krishiv-common/src/secret/classify.rs @@ -103,7 +103,25 @@ pub(crate) fn url_secret_ranges(url: &str) -> Vec> { // is exempt: `hf://user:secret@datasets/...` still has its userinfo redacted. let userinfo_scheme = !(url[..i].eq_ignore_ascii_case("hf") && url[start..].starts_with("datasets/")); - if let Some(at) = url[start..auth_end].rfind('@').filter(|_| userinfo_scheme) { + // `abfs[s]://@/...` and `wasb[s]://...`: the `@` separates the + // container from the account (Azure keys and SAS tokens are separate options, never in + // the URL). Only that shape is exempt: an `@` past the authority, or a `:` before it, + // keeps the usual redaction. + let azure = ["abfs", "abfss", "wasb", "wasbs"] + .iter() + .any(|s| url[..i].eq_ignore_ascii_case(s)); + let authority = url[start..auth_end] + .find('/') + .map_or(auth_end, |e| start + e); + let azure_container = azure + && url[start..authority] + .find('@') + .is_some_and(|at| at > 0 && !url[start..start + at].contains(':')) + && !url[authority..auth_end].contains('@'); + if let Some(at) = url[start..auth_end] + .rfind('@') + .filter(|_| userinfo_scheme && !azure_container) + { let userinfo = start..start + at; match url[userinfo.clone()].find(':') { Some(colon) => push(&mut out, start + colon + 1..userinfo.end), diff --git a/crates/krishiv-common/src/secret/tests.rs b/crates/krishiv-common/src/secret/tests.rs index 1dee10fbb..a4b070b3c 100644 --- a/crates/krishiv-common/src/secret/tests.rs +++ b/crates/krishiv-common/src/secret/tests.rs @@ -220,6 +220,9 @@ fn object_store_paths_are_not_credentials() { "hf://datasets/acme/tiny@v2/data/t.parquet", "https://huggingface.co/datasets/a/b/resolve/main/c.parquet", "s3a://bucket/key/part-00000.parquet", + "abfss://exports@lakeacct.dfs.core.windows.net/public/a.parquet", + "ABFS://exports@lakeacct.dfs.core.windows.net/x", + "wasbs://cont@acct.blob.core.windows.net/k", ] { assert!( OptionValue::from_user("path", url).as_plain().is_some(), @@ -231,6 +234,10 @@ fn object_store_paths_are_not_credentials() { "hf://user:SECRET@datasets/acme/tiny/t.parquet", "hf://SECRETTOKEN@datasets/acme/tiny/t.parquet", "HF://user:SECRET@host/x", + // Azure: a `:` in the userinfo, or an `@` past the authority, keeps the redaction. + "abfss://user:SECRET@acct.dfs.core.windows.net/x", + "abfss://acct.dfs.core.windows.net/a/SECRET@b", + "wasbs://cont@acct.blob.core.windows.net/a/SECRET@b", ] { assert!( OptionValue::from_user("path", url).as_plain().is_none(), From 1810d8794fcf4de7d362248f1a759fb6456e86d7 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Sat, 14 Nov 2026 00:44:58 +0000 Subject: [PATCH 32/47] =?UTF-8?q?session,=20server,=20connect-server,=20ex?= =?UTF-8?q?ec,=20cli:=20krishiv=5Fauth=5Ffailed=5Ftotal=20and=20krishiv=5F?= =?UTF-8?q?auth=5Fdenied=5Ftotal=20on=20/metrics,=20denials=20counted=20by?= =?UTF-8?q?=20the=20guards,=20the=20no-role=20refusal=20and=20every=20call?= =?UTF-8?q?er's=20session=20(ADR=200046=20=C2=A78,=20review=20S4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-cli/src/server.rs | 22 ++- crates/krishiv-cli/tests/server.rs | 12 ++ crates/krishiv-connect-server/CLAUDE.md | 2 +- crates/krishiv-connect-server/src/auth.rs | 30 +++- crates/krishiv-connect-server/tests/auth.rs | 7 +- crates/krishiv-connect-server/tests/tls.rs | 7 +- crates/krishiv-exec/src/lib.rs | 11 +- crates/krishiv-exec/src/metrics/prometheus.rs | 21 ++- crates/krishiv-exec/src/metrics/registry.rs | 54 +++++++ ...very_definition_for_the_snapshot_test.snap | 2 + crates/krishiv-server/CLAUDE.md | 2 +- crates/krishiv-server/src/session_guard.rs | 19 ++- crates/krishiv-server/tests/metrics.rs | 25 ++- crates/krishiv-session/CLAUDE.md | 2 +- crates/krishiv-session/public-api.txt | 25 +++ crates/krishiv-session/src/access.rs | 20 +++ crates/krishiv-session/src/auth/denials.rs | 151 ++++++++++++++++++ crates/krishiv-session/src/auth/mod.rs | 2 + crates/krishiv-session/src/auth/server.rs | 49 ++++++ crates/krishiv-session/src/session.rs | 2 + .../krishiv-session/src/session/lifecycle.rs | 1 + crates/krishiv-session/tests/server_egress.rs | 29 ++++ docs/reference/metrics-v1.md | 2 + 23 files changed, 473 insertions(+), 24 deletions(-) create mode 100644 crates/krishiv-session/src/auth/denials.rs diff --git a/crates/krishiv-cli/src/server.rs b/crates/krishiv-cli/src/server.rs index b94e3b8af..04484a2c7 100644 --- a/crates/krishiv-cli/src/server.rs +++ b/crates/krishiv-cli/src/server.rs @@ -193,10 +193,12 @@ async fn run_listeners( .map_err(|e| e.to_string())?; let authenticated = auth.is_enabled(); let auth = Arc::new(auth); + // One denial counter for the guards and every caller's session (ADR 0046 §8). + let denials = auth.denials(); let limits = SessionLimits::from_config(config); let manager = Arc::new(if authenticated { SessionManager::new( - role_sessions(base, config).map_err(|e| e.to_string())?, + role_sessions(base, config, Arc::clone(&denials)).map_err(|e| e.to_string())?, limits, ) } else { @@ -215,7 +217,7 @@ async fn run_listeners( // without it, a random token printed once (loopback only). let token = krishiv_server::internal::random_token().map_err(|e| e.to_string())?; let guard: Arc = if authenticated { - Arc::new(SessionGuard::new(Arc::clone(&auth) as Arc<_>)) + Arc::new(SessionGuard::new(Arc::clone(&auth) as Arc<_>).with_denials(Arc::clone(&denials))) } else { Arc::new(TokenGuard::new(&token)) }; @@ -224,7 +226,7 @@ async fn run_listeners( &runtime, config, guard, - authenticated, + authenticated.then(|| Arc::clone(&auth)), Arc::clone(&ready), admin_bound, )?; @@ -233,7 +235,7 @@ async fn run_listeners( ConnectServerConfig::from_config(config), ); if authenticated { - connect = connect.with_auth(ConnectAuth::new(auth)); + connect = connect.with_auth(ConnectAuth::new(auth).with_denials(denials)); } if let Some(tls) = &tls { connect = connect.with_tls(&tls.server_config()); @@ -322,10 +324,11 @@ fn admin_router( runtime: &Arc, config: &Config, guard: Arc, - authenticated: bool, + auth: Option>, ready: Arc, bound: SocketAddr, ) -> Result { + let authenticated = auth.is_some(); let metrics_on = flag(config, &METRICS_ENABLED, false); // With authentication configured every scrape authenticates (ADR 0046 §3c: a viewer // service principal); `krishiv.server.metrics.public` opens it only without. @@ -333,7 +336,14 @@ fn admin_router( let snapshot_runtime = Arc::clone(runtime); let metrics = krishiv_server::metrics::metrics_route_guarded( metrics_on, - Arc::new(move || snapshot_runtime.metrics()), + // With authentication on, the auth counters ride along (ADR 0046 §8). + Arc::new(move || { + let mut snapshot = snapshot_runtime.metrics(); + if let Some(auth) = &auth { + snapshot.merge(&auth.metrics()); + } + snapshot + }), (!public).then(|| Arc::clone(&guard)), ); let base = if cfg!(feature = "ui") && flag(config, &UI_ENABLED, true) { diff --git a/crates/krishiv-cli/tests/server.rs b/crates/krishiv-cli/tests/server.rs index 9ee475d0a..92edc9194 100644 --- a/crates/krishiv-cli/tests/server.rs +++ b/crates/krishiv-cli/tests/server.rs @@ -374,6 +374,18 @@ fn configured_tokens_guard_the_admin_routes() { assert!(ui.starts_with(expected), "{ui}"); let probe = http_get(&server.admin, "/healthz"); assert!(probe.starts_with("HTTP/1.1 200"), "{probe}"); + // ADR 0046 §8: the refusals above are on `/metrics` (the counters ride on the engine's). + let scrape = http_get_as(&server.admin, "/metrics", Some(token)); + assert!( + scrape.contains(r#"krishiv_auth_failed_total{surface="metrics",method="none"} 1"#), + "{scrape}" + ); + if cfg!(feature = "ui") { + assert!( + scrape.contains(r#"krishiv_auth_denied_total{surface="ui",privilege="MONITOR"} 1"#), + "{scrape}" + ); + } } #[cfg(unix)] diff --git a/crates/krishiv-connect-server/CLAUDE.md b/crates/krishiv-connect-server/CLAUDE.md index d8d149d8d..2533faeb7 100644 --- a/crates/krishiv-connect-server/CLAUDE.md +++ b/crates/krishiv-connect-server/CLAUDE.md @@ -23,7 +23,7 @@ Paths are relative to this crate. are malformed) and the peer address; failures `UNAUTHENTICATED` with Spark's messages, throttled or unavailable `UNAVAILABLE`, no peer address refused, a caller with no role `PERMISSION_DENIED` (42501, audited) before any session; the result rides in the request extensions (`ConnectAuth::caller` fails closed without it); auth off = - the local user. `role_sessions` is the production session factory (`PathPrefixPolicy(RolePolicy(role))`). `SessionOwner` (method, kind, tenant, name, + the local user. `role_sessions` is the production session factory (`PathPrefixPolicy(RolePolicy(role))`; each session counts its denials into the server's `DeniedCounts` under `connect`, as does the no-role refusal with `ConnectAuth::with_denials`). `SessionOwner` (method, kind, tenant, name, no groups) is the session key. `check_rpc` / `check_command`: the §3b class of every RPC and `Command` arm, no wildcard arm (a new proto arm fails to compile); `enforce` asks the session's policy for a `Check::Privilege` (`Session::authorize_privilege`, one `krishiv::audit` record per denial); `tests/auth.rs` diff --git a/crates/krishiv-connect-server/src/auth.rs b/crates/krishiv-connect-server/src/auth.rs index 781bbb2c9..3d9e34bce 100644 --- a/crates/krishiv-connect-server/src/auth.rs +++ b/crates/krishiv-connect-server/src/auth.rs @@ -42,7 +42,8 @@ use krishiv_connect::proto::v4_3::command::CommandType; use krishiv_connect::proto::v4_3::ml_command::Command as MlCommandType; use krishiv_plan::analyzer::insufficient_permissions; use krishiv_session::auth::{ - AuthFailure, Authenticated, Authenticator, Credentials, Method, Role, RolePolicy, Surface, + AuthFailure, Authenticated, Authenticator, Credentials, DeniedCounts, Method, Role, RolePolicy, + Surface, }; use krishiv_session::{ PathPrefixPolicy, PolicyVersion, Privilege, Securable, Session, StatementClass, @@ -70,6 +71,7 @@ const GLOBAL_TEMP: &str = "global_temp"; pub struct ConnectAuth { authenticator: Option>, local: Principal, + denials: Option>, } impl std::fmt::Debug for ConnectAuth { @@ -94,6 +96,7 @@ impl ConnectAuth { Self { authenticator: None, local: Principal::local_user(), + denials: None, } } @@ -104,9 +107,18 @@ impl ConnectAuth { Self { authenticator: Some(authenticator), local: Principal::local_user(), + denials: None, } } + /// The same, counting its refusals of callers with no role into `denials` + /// (`krishiv_auth_denied_total`, ADR 0046 §8; normally `ServerAuth::denials`). + #[must_use] + pub fn with_denials(mut self, denials: Arc) -> Self { + self.denials = Some(denials); + self + } + /// Whether requests are authenticated. #[must_use] pub fn is_enabled(&self) -> bool { @@ -175,6 +187,9 @@ impl ConnectAuth { error_class = error.error_class().name(), "refused a Connect caller with no role" ); + if let Some(d) = &self.denials { + d.record(Surface::Connect, Privilege::Browse); + } return Err(status_of(&error)); } Ok(caller) @@ -219,11 +234,16 @@ fn failure_status(failure: AuthFailure) -> Status { /// for its principal under `PathPrefixPolicy(RolePolicy(role))` on `base`'s runtime. Client /// code needs admin until the T014b sandbox merges (ADR 0046 N1). The path confinement of /// `config` (`krishiv.io.local.allowedRoots`, `krishiv.io.objectStore.allowedPrefixes`) is -/// checked once here, so a bad root fails at startup. +/// checked once here, so a bad root fails at startup. Each session counts its denials into +/// `denials` under the `connect` surface (`krishiv_auth_denied_total`, ADR 0046 §8). /// /// # Errors /// What `PathPrefixPolicy::from_config` refuses (`KRISHIV_INVALID_CONFIG_VALUE`). -pub fn role_sessions(base: Session, config: &Config) -> Result, KrishivError> { +pub fn role_sessions( + base: Session, + config: &Config, + denials: Arc, +) -> Result, KrishivError> { let config = config.clone(); let policy = move |role: Option| { let roles = RolePolicy::new(role, Role::Admin, PolicyVersion(0)); @@ -232,7 +252,9 @@ pub fn role_sessions(base: Session, config: &Config) -> Result, dir: tempfile::TempDir) -> Run .to_string(), ) .expect("allowed roots"); - let factory = role_sessions(Session::new().expect("session"), &config).expect("factory"); + let factory = role_sessions( + Session::new().expect("session"), + &config, + Arc::new(krishiv_session::auth::DeniedCounts::new()), + ) + .expect("factory"); let manager = Arc::new(SessionManager::new(factory, SessionLimits::default())); let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("addr"); diff --git a/crates/krishiv-connect-server/tests/tls.rs b/crates/krishiv-connect-server/tests/tls.rs index 2ac2e8d91..5530de4cc 100644 --- a/crates/krishiv-connect-server/tests/tls.rs +++ b/crates/krishiv-connect-server/tests/tls.rs @@ -74,7 +74,12 @@ async fn start() -> (SocketAddr, oneshot::Sender<()>, tempfile::TempDir) { .expect("server tls") .expect("tls on"); let auth = Arc::new(ServerAuth::new(&auth_config).expect("auth")); - let factory = role_sessions(Session::new().expect("session"), &Config::new()).expect("factory"); + let factory = role_sessions( + Session::new().expect("session"), + &Config::new(), + auth.denials(), + ) + .expect("factory"); let manager = Arc::new(SessionManager::new(factory, SessionLimits::default())); let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("addr"); diff --git a/crates/krishiv-exec/src/lib.rs b/crates/krishiv-exec/src/lib.rs index 2e1dafbf7..5a6bade85 100644 --- a/crates/krishiv-exec/src/lib.rs +++ b/crates/krishiv-exec/src/lib.rs @@ -126,11 +126,12 @@ pub use join::{ }; pub use metrics::prometheus; pub use metrics::registry::{ - BUCKETS, CACHE_LABEL, CATALOGUE, CLASS_LABEL, CounterSample, ERROR_CLASS_LABEL, GIT_SHA_LABEL, - GaugeSample, HistogramBucket, HistogramSample, LABEL_ALLOWLIST, LabelDef, - MAX_ERROR_CLASS_VALUES, MetricDef, MetricDict, MetricKind, MetricSource, MetricsRegistry, - MetricsSnapshot, OP_LABEL, OTHER_ERROR_CLASS, QueryOutcome, STATUS_LABEL, STORE_KIND_LABEL, - Status, VERSION_LABEL, cpu_secs, output_rows, scan_rows, spill_bytes, spill_files, + AUTH_METHOD_LABEL, BUCKETS, CACHE_LABEL, CATALOGUE, CLASS_LABEL, CounterSample, + ERROR_CLASS_LABEL, GIT_SHA_LABEL, GaugeSample, HistogramBucket, HistogramSample, + LABEL_ALLOWLIST, LabelDef, MAX_ERROR_CLASS_VALUES, MetricDef, MetricDict, MetricKind, + MetricSource, MetricsRegistry, MetricsSnapshot, OP_LABEL, OTHER_ERROR_CLASS, PRIVILEGE_LABEL, + QueryOutcome, STATUS_LABEL, STORE_KIND_LABEL, SURFACE_LABEL, Status, VERSION_LABEL, cpu_secs, + output_rows, scan_rows, spill_bytes, spill_files, }; pub use metrics::{ DriverStats, ExtraMetrics, Metric, OperatorEntry, OperatorMetrics, OperatorRole, diff --git a/crates/krishiv-exec/src/metrics/prometheus.rs b/crates/krishiv-exec/src/metrics/prometheus.rs index 3c290ced6..20760ec9c 100644 --- a/crates/krishiv-exec/src/metrics/prometheus.rs +++ b/crates/krishiv-exec/src/metrics/prometheus.rs @@ -507,7 +507,26 @@ mod tests { #[test] fn output_round_trips_and_covers_every_sourced_metric() { - let text = render(&full_registry().unwrap().snapshot()); + let mut snapshot = full_registry().unwrap().snapshot(); + // The authentication counters are the server's (`ServerAuth::metrics`), merged into + // the engine's snapshot by `krishiv server`. + let server = |name: &'static str, second: (&str, &str)| CounterSample { + name, + unit: "{request}", + labels: vec![ + ("surface".to_owned(), "connect".to_owned()), + (second.0.to_owned(), second.1.to_owned()), + ], + value: 1.0, + }; + snapshot.merge(&MetricsSnapshot { + counters: vec![ + server("krishiv.auth.failed", ("method", "token")), + server("krishiv.auth.denied", ("privilege", "MODIFY")), + ], + ..MetricsSnapshot::default() + }); + let text = render(&snapshot); let parsed = parse(&text).unwrap(); for def in CATALOGUE { if def.source == MetricSource::NoSource { diff --git a/crates/krishiv-exec/src/metrics/registry.rs b/crates/krishiv-exec/src/metrics/registry.rs index 55aeb6bcf..2f9ade608 100644 --- a/crates/krishiv-exec/src/metrics/registry.rs +++ b/crates/krishiv-exec/src/metrics/registry.rs @@ -174,6 +174,35 @@ pub const ERROR_CLASS_LABEL: LabelDef = LabelDef { values: &[], }; +/// `surface`: the server listener a request came in on (ADR 0046 §8). +pub const SURFACE_LABEL: LabelDef = LabelDef { + name: "surface", + values: &["connect", "admin", "metrics", "ui"], +}; + +/// `method`: how a request tried to authenticate. +pub const AUTH_METHOD_LABEL: LabelDef = LabelDef { + name: "method", + values: &["token", "oidc", "mtls", "throttle", "none"], +}; + +/// `privilege`: the privilege an access check asked for. +pub const PRIVILEGE_LABEL: LabelDef = LabelDef { + name: "privilege", + values: &[ + "SELECT", + "MODIFY", + "CREATE", + "DROP", + "MONITOR", + "USAGE", + "BROWSE", + "OPERATE", + "MANAGE", + "IMPERSONATE", + ], +}; + /// `cache`: which cache. pub const CACHE_LABEL: LabelDef = LabelDef { name: "cache", @@ -216,6 +245,9 @@ pub const LABEL_ALLOWLIST: &[&str] = &[ "store_kind", "version", "git_sha", + "surface", + "method", + "privilege", ]; /// The most distinct `error_class` label values kept per metric; further @@ -473,6 +505,28 @@ pub const CATALOGUE: &[MetricDef] = &[ since: "0.1.0", source: MetricSource::Scrape, }, + MetricDef { + name: "krishiv.auth.failed", + kind: MetricKind::Counter, + unit: "{request}", + labels: &[SURFACE_LABEL, AUTH_METHOD_LABEL], + description: "Requests to `krishiv server` refused as unauthenticated (a missing, \ + malformed, wrong or expired credential) or throttled, by listener and \ + the method tried. Zero series without authentication configured.", + since: "0.2.0", + source: MetricSource::Scrape, + }, + MetricDef { + name: "krishiv.auth.denied", + kind: MetricKind::Counter, + unit: "{request}", + labels: &[SURFACE_LABEL, PRIVILEGE_LABEL], + description: "Access checks of `krishiv server` callers refused with \ + `KRISHIV_INSUFFICIENT_PERMISSIONS`, by listener and the privilege asked \ + for.", + since: "0.2.0", + source: MetricSource::Scrape, + }, ]; /// The query's outcome, as [`Status::from_error`] classifies it. diff --git a/crates/krishiv-exec/src/metrics/snapshots/krishiv_exec__metrics__registry__tests__report_lists_every_definition_for_the_snapshot_test.snap b/crates/krishiv-exec/src/metrics/snapshots/krishiv_exec__metrics__registry__tests__report_lists_every_definition_for_the_snapshot_test.snap index 9df1021e2..6ae310a7e 100644 --- a/crates/krishiv-exec/src/metrics/snapshots/krishiv_exec__metrics__registry__tests__report_lists_every_definition_for_the_snapshot_test.snap +++ b/crates/krishiv-exec/src/metrics/snapshots/krishiv_exec__metrics__registry__tests__report_lists_every_definition_for_the_snapshot_test.snap @@ -27,3 +27,5 @@ krishiv.io.bytes.read counter By [op,store_kind] -> krishiv_io_bytes_read_bytes_ krishiv.io.request.duration histogram s [op,store_kind] -> krishiv_io_request_duration_seconds krishiv.exec.driver.stalls counter 1 [] -> krishiv_exec_driver_stalls_total krishiv.build.info gauge 1 [version,git_sha] -> krishiv_build_info +krishiv.auth.failed counter {request} [surface=connect|admin|metrics|ui,method=token|oidc|mtls|throttle|none] -> krishiv_auth_failed_total +krishiv.auth.denied counter {request} [surface=connect|admin|metrics|ui,privilege=SELECT|MODIFY|CREATE|DROP|MONITOR|USAGE|BROWSE|OPERATE|MANAGE|IMPERSONATE] -> krishiv_auth_denied_total diff --git a/crates/krishiv-server/CLAUDE.md b/crates/krishiv-server/CLAUDE.md index 4de3e8e3e..80817eecc 100644 --- a/crates/krishiv-server/CLAUDE.md +++ b/crates/krishiv-server/CLAUDE.md @@ -10,7 +10,7 @@ Admin and UI API server (REST, OpenAPI), serves the web UI. Today: `/api/v1/vers Paths are relative to this crate. - `src/lib.rs` `router()`, `serve` and `run_blocking` - `src/guard.rs` (P3-T002) `Guard`, the one check every guarded admin-listener route asks: `GuardRequest` (presented token, malformed flag, peer, `GuardSurface`, `Access`: `Authenticated`, `Metrics` = `Monitor` on `System("metrics")`, `Monitor` on `System("system")`, `Operate` on `System("queries")`), `Refusal` (401 / 503 / 403 / 500 fail closed), `TokenGuard` (the start-up token of a server without authentication), `PeerAddr` (the peer `serve_capped` records as `ConnectInfo`: the address for the throttle and, over TLS, the verified client leaf; `peer_of` / `peer_leaf_of`, passed on as `GuardRequest::with_peer_leaf` to `Credentials::peer_leaf`) -- `src/session_guard.rs` (feature `session`) `SessionGuard`: the server's `ServerAuth`, then the ADR 0046 matrix through `RolePolicy` (no role table of its own); a denial writes one `krishiv::audit` record +- `src/session_guard.rs` (feature `session`) `SessionGuard`: the server's `ServerAuth`, then the ADR 0046 matrix through `RolePolicy` (no role table of its own); a denial writes one `krishiv::audit` record and, with `with_denials`, counts into the server's `DeniedCounts` - `src/internal.rs` the UI backend (P2-T133 part a): `router_with_ui(source, token)` and `router_with_ui_guard(source, guard)` (GET needs `Access::Monitor`, anything else `Access::Operate`: operator), the `UiSource` trait (system-table rows as JSON, cancel), `/api/internal/{health,queries,queries/{id},queries/{id}/cancel,history,compare,resources,events}`; every route needs the token (`Authorization: Bearer` for any method; the `krishiv_ui_token` cookie or `?token=` only for GET), query text is shown with its literals masked, at most 16 event streams share one snapshot a period, every response of that router carries the CSP / `frame-ancestors 'none'` headers, columns are allowlisted per endpoint, `events` is SSE at one event a second - `src/admin.rs` `krishiv server`'s admin endpoints (design P3-T001 §3.5): `health_routes` (`/healthz` open, `/readyz` 200/503 from a ready flag, fixed bodies; never guarded), `with_limits` (request timeout 30 s, SSE `/events` exempt; body limit 2 MiB), `serve_capped` (connection cap, graceful shutdown); `tests/admin.rs` - `src/session_source.rs` `SessionSource` (UI over a runtime) and `EmbeddedUi` (serves it from a host process, token + stop on drop) diff --git a/crates/krishiv-server/src/session_guard.rs b/crates/krishiv-server/src/session_guard.rs index 8cc410e5c..7ca42b8f4 100644 --- a/crates/krishiv-server/src/session_guard.rs +++ b/crates/krishiv-server/src/session_guard.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use futures::future::BoxFuture; -use krishiv_session::auth::{Authenticator, Credentials, Role, RolePolicy, Surface}; +use krishiv_session::auth::{Authenticator, Credentials, DeniedCounts, Role, RolePolicy, Surface}; use krishiv_session::{PolicyVersion, Privilege, Securable}; use crate::guard::{Access, Guard, GuardRequest, GuardSurface, Refusal}; @@ -20,6 +20,7 @@ use crate::guard::{Access, Guard, GuardRequest, GuardSurface, Refusal}; #[derive(Clone)] pub struct SessionGuard { auth: Arc, + denials: Option>, } impl std::fmt::Debug for SessionGuard { @@ -32,7 +33,18 @@ impl SessionGuard { /// A guard over `auth` (normally the server's `ServerAuth`). #[must_use] pub fn new(auth: Arc) -> Self { - Self { auth } + Self { + auth, + denials: None, + } + } + + /// The same, counting its denials into `denials` (`krishiv_auth_denied_total`, ADR 0046 + /// §8; normally `ServerAuth::denials`). + #[must_use] + pub fn with_denials(mut self, denials: Arc) -> Self { + self.denials = Some(denials); + self } } @@ -97,6 +109,9 @@ impl Guard for SessionGuard { error_class = "KRISHIV_INSUFFICIENT_PERMISSIONS", "access denied" ); + if let Some(d) = &self.denials { + d.record(surface, privilege); + } Err(Refusal::Denied) }) } diff --git a/crates/krishiv-server/tests/metrics.rs b/crates/krishiv-server/tests/metrics.rs index 142d8bab3..c1f7cda3f 100644 --- a/crates/krishiv-server/tests/metrics.rs +++ b/crates/krishiv-server/tests/metrics.rs @@ -320,7 +320,30 @@ fn label<'a>(sample: &'a Sample, name: &str) -> Option<&'a str> { #[tokio::test] async fn metrics_serves_every_sourced_catalogue_metric() { - let addr = serve(router_with_metrics(true, provider(&full_registry()))).await; + // `krishiv server` merges its authentication counters into the engine's snapshot + // (`ServerAuth::metrics`); a sample of each stands in for them here. + let registry = full_registry(); + let with_auth: SnapshotProvider = Arc::new(move || { + let mut snapshot = registry.snapshot(); + let sample = |name: &'static str, second: (&str, &str)| krishiv_exec::CounterSample { + name, + unit: "{request}", + labels: vec![ + ("surface".to_owned(), "metrics".to_owned()), + (second.0.to_owned(), second.1.to_owned()), + ], + value: 1.0, + }; + snapshot.merge(&krishiv_exec::MetricsSnapshot { + counters: vec![ + sample("krishiv.auth.failed", ("method", "none")), + sample("krishiv.auth.denied", ("privilege", "MONITOR")), + ], + ..krishiv_exec::MetricsSnapshot::default() + }); + snapshot + }); + let addr = serve(router_with_metrics(true, with_auth)).await; let response = get(addr, "/metrics").await; assert!(response.starts_with("HTTP/1.1 200"), "{response}"); assert!( diff --git a/crates/krishiv-session/CLAUDE.md b/crates/krishiv-session/CLAUDE.md index 02d57512d..3fcfff0c9 100644 --- a/crates/krishiv-session/CLAUDE.md +++ b/crates/krishiv-session/CLAUDE.md @@ -24,7 +24,7 @@ Paths are relative to this crate. - `src/query_fold.rs` the bookkeeping around one running query: `QueryStart` (counters read at start), `QueryFold`/`MetricStream`/`PendingFold` (the one fold of its metrics into the engine series and `system.queries` when the stream ends, fails or is dropped; holds the admission permit), `QueuedRow` (the history row of a query waiting for admission) - `src/admission.rs` the admission seam (E5): `AdmissionController::admit(&QueryRequest) -> Permit` asked once per query in `run_on_query_logged`; default `Unlimited`; a waiting `admit` shows the query `queued` in `system.queries`, a refusal closes the row `failed`, a drop while queued `cancelled`; the permit (slot, optional per-query memory budget) lives in the query's `QueryFold` and releases on drop; `tests/admission.rs` - `src/access.rs` `StatementInfo` / `Session::statement_info` (class + read securables with columns), write/DDL checks (`authorize_statement`), `Session::authorize_privilege` (one privilege on one securable outside a statement, audited: the Connect request classes of ADR 0046 §3b), the `krishiv::audit` denial record, `PathPrefixPolicy` (local roots; object-store prefixes by store and whole key segments, `with_object_prefixes`; `from_config` is the server-mode confinement of ADR 0046 §3a from `krishiv.io.local.allowedRoots` / `krishiv.io.objectStore.allowedPrefixes`, default empty, every role, absolute roots; Azure prefixes name their account; with `allowedRoots` set the runtime's local store refuses listings that follow a link out of the roots, `SettledLocalFileSystem::confined`; `tests/path_confinement.rs`). Server-scoped store options (P3-T067b; `krishiv_io::is_server_scoped_option`: every `fs.*` key but an inline key, secret, token or SAS) need `Manage` on `System("cluster")` per read (`bind_files`, table `WITH`), per write (`authorize_write_path`; a table's own stored options are not asked again), on CREATE TABLE `OPTIONS` / `SET SERDEPROPERTIES`, on SQL `SET`, and per query while the session changed one from its builder config (`ConfStore::store_settings_changed`); `tests/server_egress.rs` -- `src/auth/` (P3-T002, design `design/P3-T002-authentication.md`, ADR 0046) server authentication: `Role` (viewer < operator < admin), `Credentials` / `Authenticated` / `AuthFailure` (Spark's two `UNAUTHENTICATED` messages), the `Authenticator` trait and `ServerAuth` (throttle, then static tokens; one `krishiv::auth` warn record and one `(surface, method)` counter per failure, never the credential); `config.rs` `AuthConfig::from_settings` (`krishiv.auth.tokens..{secret,principal,kind,role}`, `spark.connect.authenticate.token` = one admin token named `spark`, `krishiv.auth.failureThrottle.*`; unknown `krishiv.auth.*` keys refused); `secret_ref.rs` `env:`/`file:` references (a file readable by group or others refuses startup); `tokens.rs` SHA-256 digests compared in constant time against every entry (secrets >= 32 bytes, no two tokens share one); `throttle.rs` per-address failure blocks, bounded at 65,536 addresses; `mtls.rs` client certificates (`krishiv.auth.clientCertificates` off/optional/required, `clientCa`, `mtls.principalFrom` san-uri/san-dns/cn: exactly one value of that kind or refused, `mtls.userPrincipals`; roles from `krishiv.auth.roles.{viewer,operator,admin}` lists via `config.rs` `RoleLists`; a bearer token next to a certificate must name the same principal; `required` never falls back to a token); `der.rs` `CertNames::from_der`, the bounded no-recursion DER walk of the leaf's subject CN and DNS/URI SANs (fuzz target `fuzz/fuzz_targets/cert_names.rs` includes it by path); `tls_config.rs` `TlsConfig` (`krishiv.server.tls.{certFile,keyFile,minVersion}`) and `check_listener` (startup refusals: non-loopback without auth, non-loopback tokens without TLS, client certificates without TLS); `tls.rs` (feature `tls`) `ServerTls`, the rustls `ServerConfig` on aws-lc-rs with h2/http1.1 ALPN, WebPKI client verifier, a key file checked like a secret file and `reload()` swapping the certified key (a failed reload keeps the old one); `mtls_tests.rs` with in-memory handshakes against `tests/fixtures/tls/`; `oidc_config.rs` `OidcConfig` (`krishiv.auth.oidc.{issuer,audience,jwksUri,algorithms,principalClaim,roleClaim,clockSkew,jwksRefresh,allowInsecureIssuer}` and `krishiv.auth.oidc.roles.{viewer,operator,admin}`, the role-claim values by role, a namespace separate from the certificate names of `krishiv.auth.roles.*`; refused without feature `oidc`; locations https only unless the test flag, with a host, no userinfo, no fragment); `jose.rs` the pure JWS/JWK set/discovery parsers (16 KiB token cap, canonical unpadded base64url, duplicate JSON members refused, `alg` only RS256/384/512 and ES256/384, `crit` refused, header key members ignored; `check_claims`: `exp`/`nbf` with skew, exact `iss`, `aud`, principal and groups; fuzz target `fuzz/fuzz_targets/jwt_parse.rs` includes it by path); `oidc.rs` (feature `oidc`) signatures on aws-lc-rs (no `none`, no HMAC), `JwksCache` (an unknown `kid` asks for one fetch per minute, the first such request waits for it, others fail at once; a token without `kid` needs exactly one fitting key; a failed fetch or a set with no usable key keeps the keys; no keys at all is `Unavailable`) and the `krishiv-jwks` fetch thread (`HttpKeySource`: blocking reqwest, no redirects, 1 MiB cap, one 10 s deadline per fetch); the role of an OIDC principal is the highest any of its role-claim values has in the OIDC role lists; only an invalid token counts toward the throttle; `jose_tests.rs` (every build), `oidc_tests.rs` with keys generated per run, `oidc_http_tests.rs` against a loopback HTTP server; `roles.rs` `RolePolicy`, the ADR 0046 §3 matrix as an `AccessPolicy` (viewer: `Browse`, own `system.*` rows, `/metrics`; operator: `Select`, `Usage`, `Monitor`, `Operate`; admin: `Modify`/`Create`/`Drop`/`Manage`; client code at `krishiv.auth.udf.minRole`; `Impersonate` never; no role denies all), `tests/roles_matrix.rs`. Metadata statements need `Browse` (`authorize` falls back to `Select` for providers that know only `Select`); `ADMIN`-class statements need `Manage` on `System("cluster")` +- `src/auth/` (P3-T002, design `design/P3-T002-authentication.md`, ADR 0046) server authentication: `Role` (viewer < operator < admin), `Credentials` / `Authenticated` / `AuthFailure` (Spark's two `UNAUTHENTICATED` messages), the `Authenticator` trait and `ServerAuth` (throttle, then static tokens; one `krishiv::auth` warn record and one `(surface, method)` counter per failure, never the credential; `ServerAuth::metrics` gives `krishiv.auth.failed` / `krishiv.auth.denied` as counter samples for `/metrics`); `denials.rs` `DeniedCounts`, the server's `(surface, privilege)` denial counters (ADR 0046 §8), shared by its guards and its sessions (`Session::count_denials`: every `KRISHIV_INSUFFICIENT_PERMISSIONS` the session's `log_denial` writes is counted, by the error's `privilege` parameter); `config.rs` `AuthConfig::from_settings` (`krishiv.auth.tokens..{secret,principal,kind,role}`, `spark.connect.authenticate.token` = one admin token named `spark`, `krishiv.auth.failureThrottle.*`; unknown `krishiv.auth.*` keys refused); `secret_ref.rs` `env:`/`file:` references (a file readable by group or others refuses startup); `tokens.rs` SHA-256 digests compared in constant time against every entry (secrets >= 32 bytes, no two tokens share one); `throttle.rs` per-address failure blocks, bounded at 65,536 addresses; `mtls.rs` client certificates (`krishiv.auth.clientCertificates` off/optional/required, `clientCa`, `mtls.principalFrom` san-uri/san-dns/cn: exactly one value of that kind or refused, `mtls.userPrincipals`; roles from `krishiv.auth.roles.{viewer,operator,admin}` lists via `config.rs` `RoleLists`; a bearer token next to a certificate must name the same principal; `required` never falls back to a token); `der.rs` `CertNames::from_der`, the bounded no-recursion DER walk of the leaf's subject CN and DNS/URI SANs (fuzz target `fuzz/fuzz_targets/cert_names.rs` includes it by path); `tls_config.rs` `TlsConfig` (`krishiv.server.tls.{certFile,keyFile,minVersion}`) and `check_listener` (startup refusals: non-loopback without auth, non-loopback tokens without TLS, client certificates without TLS); `tls.rs` (feature `tls`) `ServerTls`, the rustls `ServerConfig` on aws-lc-rs with h2/http1.1 ALPN, WebPKI client verifier, a key file checked like a secret file and `reload()` swapping the certified key (a failed reload keeps the old one); `mtls_tests.rs` with in-memory handshakes against `tests/fixtures/tls/`; `oidc_config.rs` `OidcConfig` (`krishiv.auth.oidc.{issuer,audience,jwksUri,algorithms,principalClaim,roleClaim,clockSkew,jwksRefresh,allowInsecureIssuer}` and `krishiv.auth.oidc.roles.{viewer,operator,admin}`, the role-claim values by role, a namespace separate from the certificate names of `krishiv.auth.roles.*`; refused without feature `oidc`; locations https only unless the test flag, with a host, no userinfo, no fragment); `jose.rs` the pure JWS/JWK set/discovery parsers (16 KiB token cap, canonical unpadded base64url, duplicate JSON members refused, `alg` only RS256/384/512 and ES256/384, `crit` refused, header key members ignored; `check_claims`: `exp`/`nbf` with skew, exact `iss`, `aud`, principal and groups; fuzz target `fuzz/fuzz_targets/jwt_parse.rs` includes it by path); `oidc.rs` (feature `oidc`) signatures on aws-lc-rs (no `none`, no HMAC), `JwksCache` (an unknown `kid` asks for one fetch per minute, the first such request waits for it, others fail at once; a token without `kid` needs exactly one fitting key; a failed fetch or a set with no usable key keeps the keys; no keys at all is `Unavailable`) and the `krishiv-jwks` fetch thread (`HttpKeySource`: blocking reqwest, no redirects, 1 MiB cap, one 10 s deadline per fetch); the role of an OIDC principal is the highest any of its role-claim values has in the OIDC role lists; only an invalid token counts toward the throttle; `jose_tests.rs` (every build), `oidc_tests.rs` with keys generated per run, `oidc_http_tests.rs` against a loopback HTTP server; `roles.rs` `RolePolicy`, the ADR 0046 §3 matrix as an `AccessPolicy` (viewer: `Browse`, own `system.*` rows, `/metrics`; operator: `Select`, `Usage`, `Monitor`, `Operate`; admin: `Modify`/`Create`/`Drop`/`Manage`; client code at `krishiv.auth.udf.minRole`; `Impersonate` never; no role denies all), `tests/roles_matrix.rs`. Metadata statements need `Browse` (`authorize` falls back to `Select` for providers that know only `Select`); `ADMIN`-class statements need `Manage` on `System("cluster")` - `src/partitions.rs` partitioned and bucketed tables: the layout `CREATE TABLE` stores (partition columns last), the partitions the catalog tracks (`ADD PARTITION`, `SET LOCATION` and static `INSERT` values cast to the column types, `DROP PARTITION` of a managed table: log first, then the directories; each partitioned table has a read/write lock (`InMemoryCatalogStore::partition_ddl` / `partition_write`, keyed by location, weak entries): `RENAME`/`DROP PARTITION`, `MSCK REPAIR` and `TRUNCATE` hold it exclusively (across their log commit retries) and re-read the entry under it, an `INSERT` holds it shared from its write to its partition registration; external tables refused, registered after `INSERT`/CTAS), `DESCRIBE ... PARTITION`, `SHOW PARTITIONS`, `ANALYZE ... PARTITION`; `tests/partitioned_tables.rs` - `src/column_stats.rs` `ANALYZE TABLE .. FOR COLUMNS` (`CommandUtils.statExprs`): one aggregate over the table, kept in `CatalogTable::column_stats` (the catalog is in memory, no on-disk format), printed by `DESCRIBE EXTENDED t col`; a write, `TRUNCATE`, `ADD PARTITION`, `DROP PARTITION` or `SET LOCATION` drops them (`updateTableStats`); published only to the entry the aggregate read (`CatalogTable::incarnation`, set by the store at create, kept by `replace_table`), so a table dropped and created again meanwhile gets none, nor one whose data changed after it was read (`CatalogTable::data_version`, moved by `data_changed`, which every stats-clearing change calls); `MSCK REPAIR` keeps them, as Spark 4.2.0 does; `tests/char_varchar_write.rs` - `src/table_commands/files.rs` a managed table's directory: `authorize_path` (the policy per privilege), `deletable_dir` (inside the root it was created under, no links, `MODIFY`), `delete_table_files`, `remove_dir` diff --git a/crates/krishiv-session/public-api.txt b/crates/krishiv-session/public-api.txt index d6f798e37..cb5fa5293 100644 --- a/crates/krishiv-session/public-api.txt +++ b/crates/krishiv-session/public-api.txt @@ -373,6 +373,28 @@ pub fn krishiv_session::auth::Credentials<'_>::fmt(&self, &mut core::fmt::Format impl<'a> core::clone::Clone for krishiv_session::auth::Credentials<'a> pub fn krishiv_session::auth::Credentials<'a>::clone(&self) -> krishiv_session::auth::Credentials<'a> impl<'a> core::marker::Copy for krishiv_session::auth::Credentials<'a> +#[non_exhaustive] pub struct krishiv_session::auth::DeniedCount +pub krishiv_session::auth::DeniedCount::count: u64 +pub krishiv_session::auth::DeniedCount::privilege: krishiv_plan::analyzer::catalog::Privilege +pub krishiv_session::auth::DeniedCount::surface: krishiv_session::auth::Surface +impl core::clone::Clone for krishiv_session::auth::DeniedCount +pub fn krishiv_session::auth::DeniedCount::clone(&self) -> krishiv_session::auth::DeniedCount +impl core::cmp::Eq for krishiv_session::auth::DeniedCount +impl core::cmp::PartialEq for krishiv_session::auth::DeniedCount +pub fn krishiv_session::auth::DeniedCount::eq(&self, &krishiv_session::auth::DeniedCount) -> bool +impl core::fmt::Debug for krishiv_session::auth::DeniedCount +pub fn krishiv_session::auth::DeniedCount::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for krishiv_session::auth::DeniedCount +pub struct krishiv_session::auth::DeniedCounts +impl krishiv_session::auth::DeniedCounts +pub fn krishiv_session::auth::DeniedCounts::counts(&self) -> alloc::vec::Vec +pub fn krishiv_session::auth::DeniedCounts::new() -> Self +pub fn krishiv_session::auth::DeniedCounts::record(&self, krishiv_session::auth::Surface, krishiv_plan::analyzer::catalog::Privilege) +pub fn krishiv_session::auth::DeniedCounts::record_named(&self, krishiv_session::auth::Surface, &str) +impl core::default::Default for krishiv_session::auth::DeniedCounts +pub fn krishiv_session::auth::DeniedCounts::default() -> Self +impl core::fmt::Debug for krishiv_session::auth::DeniedCounts +pub fn krishiv_session::auth::DeniedCounts::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result #[non_exhaustive] pub struct krishiv_session::auth::FailureCount pub krishiv_session::auth::FailureCount::count: u64 pub krishiv_session::auth::FailureCount::method: krishiv_session::auth::Method @@ -470,8 +492,10 @@ pub fn krishiv_session::auth::RolePolicy::version(&self) -> krishiv_plan::analyz pub struct krishiv_session::auth::ServerAuth impl krishiv_session::auth::ServerAuth pub fn krishiv_session::auth::ServerAuth::authenticate_at(&self, &krishiv_session::auth::Credentials<'_>, std::time::Instant) -> core::result::Result +pub fn krishiv_session::auth::ServerAuth::denials(&self) -> alloc::sync::Arc pub fn krishiv_session::auth::ServerAuth::failure_counts(&self) -> alloc::vec::Vec pub fn krishiv_session::auth::ServerAuth::is_enabled(&self) -> bool +pub fn krishiv_session::auth::ServerAuth::metrics(&self) -> krishiv_exec::metrics::registry::MetricsSnapshot pub fn krishiv_session::auth::ServerAuth::new(&krishiv_session::auth::AuthConfig) -> core::result::Result impl core::fmt::Debug for krishiv_session::auth::ServerAuth pub fn krishiv_session::auth::ServerAuth::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result @@ -1031,6 +1055,7 @@ pub fn krishiv_session::RuntimeConf::fmt(&self, &mut core::fmt::Formatter<'_>) - pub struct krishiv_session::Session impl krishiv_session::Session pub async fn krishiv_session::Session::authorize_privilege(&self, &krishiv_plan::analyzer::catalog::Securable, krishiv_plan::analyzer::catalog::Privilege, krishiv_sql::classify::StatementClass) -> core::result::Result<(), krishiv_common::error::KrishivError> +pub fn krishiv_session::Session::count_denials(&self, alloc::sync::Arc, krishiv_session::auth::Surface) -> bool impl krishiv_session::Session pub fn krishiv_session::Session::builder() -> krishiv_session::SessionBuilder pub fn krishiv_session::Session::cache_reserved(&self) -> usize diff --git a/crates/krishiv-session/src/access.rs b/crates/krishiv-session/src/access.rs index c47df62c1..1c21a13f7 100644 --- a/crates/krishiv-session/src/access.rs +++ b/crates/krishiv-session/src/access.rs @@ -167,6 +167,15 @@ pub(crate) fn audit( /// an access check fails closed because it cannot name what a statement /// touches (`KRISHIV_UNSUPPORTED_FEATURE`). fn log_denial(session: &Session, class: StatementClass, e: &KrishivError) { + if e.error_class() == ErrorClass::KrishivInsufficientPermissions + && let Some((counts, surface)) = session.state.denials.get() + && let Some((_, privilege)) = e + .message_parameters() + .iter() + .find(|(name, _)| *name == "privilege") + { + counts.record_named(*surface, privilege); + } tracing::warn!( target: "krishiv::audit", principal = %session.state.principal.name, @@ -178,6 +187,17 @@ fn log_denial(session: &Session, class: StatementClass, e: &KrishivError) { } impl Session { + /// Counts every `KRISHIV_INSUFFICIENT_PERMISSIONS` denial of this session into `counts` + /// under `surface` from now on (a server's sessions: `krishiv_auth_denied_total`, ADR 0046 + /// §8). Set once; a second call changes nothing and returns `false`. + pub fn count_denials( + &self, + counts: Arc, + surface: crate::auth::Surface, + ) -> bool { + self.state.denials.set((counts, surface)).is_ok() + } + /// Checks what `statement` (of `class`) writes, creates or drops, and /// what it reads without a query, before it runs; an `ADMIN` statement /// needs `Manage` on the cluster (ADR 0046 §4). Names are located as analysis locates diff --git a/crates/krishiv-session/src/auth/denials.rs b/crates/krishiv-session/src/auth/denials.rs new file mode 100644 index 000000000..50ab13441 --- /dev/null +++ b/crates/krishiv-session/src/auth/denials.rs @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 +//! The denial counter of a server (`krishiv_auth_denied_total{surface, privilege}`, ADR 0046 +//! §8): one count per access check refused with `KRISHIV_INSUFFICIENT_PERMISSIONS`, by the +//! listener the caller came in on and the privilege asked for. A server shares one +//! [`DeniedCounts`] between its listeners' guards and its sessions +//! ([`crate::Session::count_denials`]). + +use std::sync::atomic::{AtomicU64, Ordering}; + +use krishiv_plan::analyzer::Privilege; + +use super::Surface; + +/// Every privilege, in counter order (a closed set: the label stays bounded). +const PRIVILEGES: [Privilege; 10] = [ + Privilege::Select, + Privilege::Modify, + Privilege::Create, + Privilege::Drop, + Privilege::Monitor, + Privilege::Usage, + Privilege::Browse, + Privilege::Operate, + Privilege::Manage, + Privilege::Impersonate, +]; + +/// One denial counter. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct DeniedCount { + /// The listener. + pub surface: Surface, + /// The privilege asked for. + pub privilege: Privilege, + /// Denials so far. + pub count: u64, +} + +/// The denial counters of one server. +#[derive(Debug)] +pub struct DeniedCounts { + /// Indexed by `surface * PRIVILEGES.len() + privilege`. + counts: [AtomicU64; Surface::ALL.len() * PRIVILEGES.len()], +} + +impl Default for DeniedCounts { + fn default() -> Self { + Self { + counts: std::array::from_fn(|_| AtomicU64::new(0)), + } + } +} + +impl DeniedCounts { + /// Counters at zero. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Counts one denial of `privilege` on `surface`. + pub fn record(&self, surface: Surface, privilege: Privilege) { + if let Some(i) = index(surface, privilege) { + self.counts[i].fetch_add(1, Ordering::Relaxed); + } + } + + /// Counts one denial on `surface` of the privilege named as `Privilege`'s `Display` writes it + /// (the `privilege` parameter of `KRISHIV_INSUFFICIENT_PERMISSIONS`); an unknown name counts + /// nothing. + pub fn record_named(&self, surface: Surface, privilege: &str) { + if let Some(p) = PRIVILEGES.iter().find(|p| p.to_string() == privilege) { + self.record(surface, *p); + } + } + + /// Every counter, by surface then privilege. + #[must_use] + pub fn counts(&self) -> Vec { + let mut out = Vec::with_capacity(self.counts.len()); + for surface in Surface::ALL { + for privilege in PRIVILEGES { + if let Some(i) = index(surface, privilege) { + out.push(DeniedCount { + surface, + privilege, + count: self.counts[i].load(Ordering::Relaxed), + }); + } + } + } + out + } +} + +fn index(surface: Surface, privilege: Privilege) -> Option { + let s = Surface::ALL.iter().position(|x| *x == surface)?; + let p = PRIVILEGES.iter().position(|x| *x == privilege)?; + Some(s * PRIVILEGES.len() + p) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_surface_and_privilege_counts_on_its_own() { + let d = DeniedCounts::new(); + d.record(Surface::Connect, Privilege::Modify); + d.record(Surface::Connect, Privilege::Modify); + d.record_named(Surface::Ui, "OPERATE"); + d.record_named(Surface::Ui, "NOT_A_PRIVILEGE"); + let counts = d.counts(); + assert_eq!(counts.len(), Surface::ALL.len() * PRIVILEGES.len()); + let get = |s, p| { + counts + .iter() + .find(|c| c.surface == s && c.privilege == p) + .map(|c| c.count) + }; + assert_eq!(get(Surface::Connect, Privilege::Modify), Some(2)); + assert_eq!(get(Surface::Ui, Privilege::Operate), Some(1)); + assert_eq!(get(Surface::Admin, Privilege::Modify), Some(0)); + assert_eq!(counts.iter().map(|c| c.count).sum::(), 3); + } + + #[test] + fn every_privilege_has_a_counter() { + // A new `Privilege` variant must be added to `PRIVILEGES` (and the metric's label). + for p in PRIVILEGES { + assert!(index(Surface::Connect, p).is_some()); + } + let names: Vec = PRIVILEGES.iter().map(ToString::to_string).collect(); + assert_eq!( + names, + [ + "SELECT", + "MODIFY", + "CREATE", + "DROP", + "MONITOR", + "USAGE", + "BROWSE", + "OPERATE", + "MANAGE", + "IMPERSONATE" + ] + ); + } +} diff --git a/crates/krishiv-session/src/auth/mod.rs b/crates/krishiv-session/src/auth/mod.rs index a03d7510b..4276dd7c5 100644 --- a/crates/krishiv-session/src/auth/mod.rs +++ b/crates/krishiv-session/src/auth/mod.rs @@ -27,6 +27,7 @@ //! credential) and its failure counters. mod config; +mod denials; mod der; #[cfg_attr( all(not(feature = "oidc"), not(test)), @@ -69,6 +70,7 @@ use futures::future::BoxFuture; use krishiv_common::principal::Principal; pub use config::{AuthConfig, RoleLists, ThrottleConfig, TokenSpec, is_auth_key}; +pub use denials::{DeniedCount, DeniedCounts}; pub use der::{CertNames, DerError}; pub use jose::Alg; pub use mtls::{ClientCertificates, MtlsConfig, PrincipalFrom}; diff --git a/crates/krishiv-session/src/auth/server.rs b/crates/krishiv-session/src/auth/server.rs index 9ebb60431..5e789b9ab 100644 --- a/crates/krishiv-session/src/auth/server.rs +++ b/crates/krishiv-session/src/auth/server.rs @@ -58,6 +58,8 @@ pub struct ServerAuth { oidc_roles: RoleLists, /// Indexed by `surface * Method::ALL.len() + method`. failures: [AtomicU64; Surface::ALL.len() * Method::ALL.len()], + /// The denials of this server's callers (its guards and sessions count into it). + denied: std::sync::Arc, } impl ServerAuth { @@ -122,6 +124,7 @@ impl ServerAuth { #[cfg(feature = "oidc")] oidc_roles: config.oidc.roles.clone(), failures: std::array::from_fn(|_| AtomicU64::new(0)), + denied: std::sync::Arc::new(super::DeniedCounts::new()), }) } @@ -268,6 +271,52 @@ impl ServerAuth { } } + /// The denial counters of this server's callers, shared with whatever counts into them + /// (`krishiv_auth_denied_total`, ADR 0046 §8). + #[must_use] + pub fn denials(&self) -> std::sync::Arc { + std::sync::Arc::clone(&self.denied) + } + + /// `krishiv_auth_failed_total{surface, method}` and `krishiv_auth_denied_total{surface, + /// privilege}` (ADR 0046 §8), every series including the zeros, for the server's `/metrics` + /// (merged into the engine's snapshot). + #[must_use] + pub fn metrics(&self) -> krishiv_exec::MetricsSnapshot { + let mut out = krishiv_exec::MetricsSnapshot::default(); + for f in self.failure_counts() { + out.counters.push(krishiv_exec::CounterSample { + name: "krishiv.auth.failed", + unit: "{request}", + labels: vec![ + ("surface".to_owned(), f.surface.as_str().to_owned()), + ("method".to_owned(), f.method.as_str().to_owned()), + ], + #[expect( + clippy::cast_precision_loss, + reason = "a counter past 2^53 loses only its low bits" + )] + value: f.count as f64, + }); + } + for d in self.denied.counts() { + out.counters.push(krishiv_exec::CounterSample { + name: "krishiv.auth.denied", + unit: "{request}", + labels: vec![ + ("surface".to_owned(), d.surface.as_str().to_owned()), + ("privilege".to_owned(), d.privilege.to_string()), + ], + #[expect( + clippy::cast_precision_loss, + reason = "a counter past 2^53 loses only its low bits" + )] + value: d.count as f64, + }); + } + out + } + /// The failure counters, every `(surface, method)` pair in order. #[must_use] pub fn failure_counts(&self) -> Vec { diff --git a/crates/krishiv-session/src/session.rs b/crates/krishiv-session/src/session.rs index 7a6a52804..72c6c1fdb 100644 --- a/crates/krishiv-session/src/session.rs +++ b/crates/krishiv-session/src/session.rs @@ -185,6 +185,8 @@ pub(crate) struct SessionState { /// while expanding a view point into the view's text, not the outer /// statement's (`SourceId(0)` stays the outer statement's). sql_sources: AtomicU32, + /// Where this session counts its denials (a server's sessions, `Session::count_denials`). + pub(crate) denials: std::sync::OnceLock<(Arc, crate::auth::Surface)>, } impl Drop for SessionState { diff --git a/crates/krishiv-session/src/session/lifecycle.rs b/crates/krishiv-session/src/session/lifecycle.rs index 7d1188aca..56e5a758a 100644 --- a/crates/krishiv-session/src/session/lifecycle.rs +++ b/crates/krishiv-session/src/session/lifecycle.rs @@ -140,6 +140,7 @@ impl Session { admission: parts.admission.unwrap_or_else(|| Arc::new(Unlimited)), statements: AtomicU64::new(0), sql_sources: AtomicU32::new(seed.sql_sources), + denials: std::sync::OnceLock::new(), }), } } diff --git a/crates/krishiv-session/tests/server_egress.rs b/crates/krishiv-session/tests/server_egress.rs index 7dab3da29..c9f4145c0 100644 --- a/crates/krishiv-session/tests/server_egress.rs +++ b/crates/krishiv-session/tests/server_egress.rs @@ -419,3 +419,32 @@ fn an_admins_stored_options_serve_other_writers() { Err(DENIED.to_owned()) ); } + +/// A server's sessions count their denials (`krishiv_auth_denied_total`, ADR 0046 §8) into the +/// counter they were given, under its surface and the privilege asked for; an allowed read +/// counts nothing. +#[test] +fn a_session_counts_its_denials() { + use krishiv_session::auth::{DeniedCounts, Surface}; + let tmp = data(); + let path = tmp.path().join("a.csv"); + let path = path.to_str().unwrap(); + let counts = Arc::new(DeniedCounts::new()); + let viewer = session(tmp.path(), Role::Viewer, &[]); + assert!(viewer.count_denials(Arc::clone(&counts), Surface::Connect)); + assert!( + !viewer.count_denials(Arc::new(DeniedCounts::new()), Surface::Ui), + "set once" + ); + assert_eq!(read_with(&viewer, path, &[]), Err(DENIED.to_owned())); + let operator = session(tmp.path(), Role::Operator, &[]); + operator.count_denials(Arc::clone(&counts), Surface::Connect); + assert_eq!(read_with(&operator, path, &[]), Ok(2)); + let nonzero: Vec<_> = counts + .counts() + .into_iter() + .filter(|c| c.count > 0) + .map(|c| (c.surface, c.privilege, c.count)) + .collect(); + assert_eq!(nonzero, [(Surface::Connect, Privilege::Select, 1)]); +} diff --git a/docs/reference/metrics-v1.md b/docs/reference/metrics-v1.md index aafbeccc1..c771b8e7e 100644 --- a/docs/reference/metrics-v1.md +++ b/docs/reference/metrics-v1.md @@ -43,3 +43,5 @@ The `denied` value of `status` is reserved: access-policy denials fail before ex | `krishiv.io.request.duration` | `krishiv_io_request_duration_seconds` | histogram | `s` | op; store_kind | no source yet | No source yet: the I/O layer keeps no per-request durations, so this entry emits nothing until they land. | | `krishiv.exec.driver.stalls` | `krishiv_exec_driver_stalls_total` | counter | `1` | — | query fold | Driver polls longer than the stall threshold, summed over the query's drivers. | | `krishiv.build.info` | `krishiv_build_info` | gauge | `1` | version; git_sha | scrape | The build: always 1, labelled with its version and commit. | +| `krishiv.auth.failed` | `krishiv_auth_failed_total` | counter | `{request}` | surface(connect, admin, metrics, ui); method(token, oidc, mtls, throttle, none) | scrape | Requests to `krishiv server` refused as unauthenticated (a missing, malformed, wrong or expired credential) or throttled, by listener and the method tried. Zero series without authentication configured. | +| `krishiv.auth.denied` | `krishiv_auth_denied_total` | counter | `{request}` | surface(connect, admin, metrics, ui); privilege(SELECT, MODIFY, CREATE, DROP, MONITOR, USAGE, BROWSE, OPERATE, MANAGE, IMPERSONATE) | scrape | Access checks of `krishiv server` callers refused with `KRISHIV_INSUFFICIENT_PERMISSIONS`, by listener and the privilege asked for. | From 0f35328c8125f6bb19bebdee287322f9c5cf5b57 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:25:46 +0000 Subject: [PATCH 33/47] session, connect-server, exec: S4 review fixes: a denial is counted once on the write error path, new and cloned sessions keep the counter, count tests on Connect Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-connect-server/tests/auth.rs | 24 +++++++++++++++++-- crates/krishiv-exec/src/metrics/registry.rs | 5 ++-- crates/krishiv-session/src/auth/denials.rs | 2 +- .../krishiv-session/src/session/lifecycle.rs | 16 +++++++++++-- crates/krishiv-session/src/session/ops.rs | 7 ++++-- crates/krishiv-session/tests/access_policy.rs | 5 ++++ docs/reference/metrics-v1.md | 4 ++-- 7 files changed, 52 insertions(+), 11 deletions(-) diff --git a/crates/krishiv-connect-server/tests/auth.rs b/crates/krishiv-connect-server/tests/auth.rs index 332a3b426..a2ad62dbd 100644 --- a/crates/krishiv-connect-server/tests/auth.rs +++ b/crates/krishiv-connect-server/tests/auth.rs @@ -22,9 +22,11 @@ use krishiv_connect_server::{ ConnectAuth, ConnectServer, ConnectServerConfig, SessionLimits, SessionManager, role_sessions, }; use krishiv_session::BoxFuture; +use krishiv_session::Privilege; use krishiv_session::Session; use krishiv_session::auth::{ AuthConfig, AuthFailure, Authenticated, Authenticator, Credentials, Method, Role, ServerAuth, + Surface, }; use prost::Message; use tokio::net::TcpListener; @@ -39,6 +41,8 @@ const OPERATOR: &str = "operator-token-0123456789abcdefghijklmnopqrstu"; struct Running { manager: Arc, + /// The denial counter the sessions and the no-role refusal count into. + denials: Arc, channel: Channel, _stop: oneshot::Sender<()>, _dir: tempfile::TempDir, @@ -90,17 +94,18 @@ async fn start_with(auth: Arc, dir: tempfile::TempDir) -> Run .to_string(), ) .expect("allowed roots"); + let denials = Arc::new(krishiv_session::auth::DeniedCounts::new()); let factory = role_sessions( Session::new().expect("session"), &config, - Arc::new(krishiv_session::auth::DeniedCounts::new()), + Arc::clone(&denials), ) .expect("factory"); let manager = Arc::new(SessionManager::new(factory, SessionLimits::default())); let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("addr"); let server = ConnectServer::new(Arc::clone(&manager), ConnectServerConfig::default()) - .with_auth(ConnectAuth::new(auth)); + .with_auth(ConnectAuth::new(auth).with_denials(Arc::clone(&denials))); let (stop, stopped) = oneshot::channel::<()>(); tokio::spawn(server.serve(listener, async { let _ = stopped.await; @@ -112,6 +117,7 @@ async fn start_with(auth: Arc, dir: tempfile::TempDir) -> Run .expect("connect"); Running { manager, + denials, channel, _stop: stop, _dir: dir, @@ -403,6 +409,8 @@ async fn sessions_get_the_role_policy_and_the_path_confinement() { .expect_err("confined"); assert_eq!(s.code(), Code::PermissionDenied); assert_eq!(error_class(&s), "KRISHIV_INSUFFICIENT_PERMISSIONS"); + // ADR 0046 §8: the session counted it once, under `connect` and the privilege asked for. + assert_eq!(denied(&run), [(Surface::Connect, Privilege::Select, 1)]); // Inside the roots the same read gets past the policy (the file does not exist). let inside = run._dir.path().join("data").join("missing.parquet"); let s = execute( @@ -453,6 +461,18 @@ async fn a_caller_with_no_role_gets_no_session() { .expect_err("no role"); assert_eq!(s.code(), Code::PermissionDenied); assert_eq!(run.manager.counts().live, 0); + // Each refusal is one denial of `BROWSE` on `connect`. + assert_eq!(denied(&run), [(Surface::Connect, Privilege::Browse, 2)]); +} + +/// The non-zero denial counters. +fn denied(run: &Running) -> Vec<(Surface, Privilege, u64)> { + run.denials + .counts() + .into_iter() + .filter(|c| c.count > 0) + .map(|c| (c.surface, c.privilege, c.count)) + .collect() } /// Sends `body` (a message that is no `ExecutePlanRequest`: field 1 as a varint where a diff --git a/crates/krishiv-exec/src/metrics/registry.rs b/crates/krishiv-exec/src/metrics/registry.rs index 2f9ade608..c3cb33126 100644 --- a/crates/krishiv-exec/src/metrics/registry.rs +++ b/crates/krishiv-exec/src/metrics/registry.rs @@ -512,7 +512,8 @@ pub const CATALOGUE: &[MetricDef] = &[ labels: &[SURFACE_LABEL, AUTH_METHOD_LABEL], description: "Requests to `krishiv server` refused as unauthenticated (a missing, \ malformed, wrong or expired credential) or throttled, by listener and \ - the method tried. Zero series without authentication configured.", + the method tried. Served only by `krishiv server` with authentication \ + configured.", since: "0.2.0", source: MetricSource::Scrape, }, @@ -523,7 +524,7 @@ pub const CATALOGUE: &[MetricDef] = &[ labels: &[SURFACE_LABEL, PRIVILEGE_LABEL], description: "Access checks of `krishiv server` callers refused with \ `KRISHIV_INSUFFICIENT_PERMISSIONS`, by listener and the privilege asked \ - for.", + for. Served only by `krishiv server` with authentication configured.", since: "0.2.0", source: MetricSource::Scrape, }, diff --git a/crates/krishiv-session/src/auth/denials.rs b/crates/krishiv-session/src/auth/denials.rs index 50ab13441..afeb90724 100644 --- a/crates/krishiv-session/src/auth/denials.rs +++ b/crates/krishiv-session/src/auth/denials.rs @@ -69,7 +69,7 @@ impl DeniedCounts { /// Counts one denial on `surface` of the privilege named as `Privilege`'s `Display` writes it /// (the `privilege` parameter of `KRISHIV_INSUFFICIENT_PERMISSIONS`); an unknown name counts /// nothing. - pub fn record_named(&self, surface: Surface, privilege: &str) { + pub(crate) fn record_named(&self, surface: Surface, privilege: &str) { if let Some(p) = PRIVILEGES.iter().find(|p| p.to_string() == privilege) { self.record(surface, *p); } diff --git a/crates/krishiv-session/src/session/lifecycle.rs b/crates/krishiv-session/src/session/lifecycle.rs index 56e5a758a..6a0ecdf3a 100644 --- a/crates/krishiv-session/src/session/lifecycle.rs +++ b/crates/krishiv-session/src/session/lifecycle.rs @@ -176,7 +176,7 @@ impl Session { // The runtime's starting configuration, validated when it was built // (for a private runtime, the builder's configuration). let config = Config::clone(&runtime.config); - Self::on_runtime(runtime, config, parts) + inherit_denials(self, Self::on_runtime(runtime, config, parts)) } /// [`Self::new_session`] acting for `principal` under `policy` instead of this session's @@ -281,7 +281,10 @@ impl Session { // A `close` that ran while the copy was made wins: no live clone of // a closed session. self.check_open()?; - Ok(Self::seeded(Arc::clone(&state.runtime), parts, seed)) + Ok(inherit_denials( + self, + Self::seeded(Arc::clone(&state.runtime), parts, seed), + )) } /// The runtime this session runs on. @@ -525,6 +528,15 @@ struct Seed { cwd: Option, } +/// `to`, counting its denials where `from` counts them (`new_session` and `clone_session` keep +/// the principal, so they keep the server's denial counter too). +fn inherit_denials(from: &Session, to: Session) -> Session { + if let Some((counts, surface)) = from.state.denials.get() { + to.count_denials(Arc::clone(counts), *surface); + } + to +} + #[cfg(test)] mod tests { use krishiv_exec::{ExecConfig, QueryContext, QueryId, WorkloadClass}; diff --git a/crates/krishiv-session/src/session/ops.rs b/crates/krishiv-session/src/session/ops.rs index ee682d47e..888be7c05 100644 --- a/crates/krishiv-session/src/session/ops.rs +++ b/crates/krishiv-session/src/session/ops.rs @@ -351,8 +351,11 @@ impl Session { } } // A store error after the anchor is open may be a link swapped into - // the path: the audited denial is what the caller sees then. - if written.is_err() + // the path: the audited denial is what the caller sees then. A denial + // is already audited (and counted): it is not asked about again. + if written + .as_ref() + .is_err_and(|e| e.error_class().name() != "KRISHIV_INSUFFICIENT_PERMISSIONS") && let Err(denied) = crate::access::still_physical(self, &at) && denied.error_class().name() == "KRISHIV_INSUFFICIENT_PERMISSIONS" { diff --git a/crates/krishiv-session/tests/access_policy.rs b/crates/krishiv-session/tests/access_policy.rs index 7e987a8b4..733612325 100644 --- a/crates/krishiv-session/tests/access_policy.rs +++ b/crates/krishiv-session/tests/access_policy.rs @@ -767,6 +767,8 @@ fn a_directory_swapped_during_the_write_is_denied_and_cleaned_up() { }; let policy = PathPrefixPolicy::new([&root], Arc::new(AllowAll)).unwrap(); let s = session(Arc::new(policy)); + let counts = Arc::new(krishiv_session::auth::DeniedCounts::new()); + s.count_denials(Arc::clone(&counts), krishiv_session::auth::Surface::Connect); let df = block_on(s.sql("SELECT id FROM range(0, 20000, 1, 1)")).unwrap(); let result = block_on( df.write() @@ -778,6 +780,9 @@ fn a_directory_swapped_during_the_write_is_denied_and_cleaned_up() { "the swap happened during the write" ); assert_eq!(class(result), DENIED); + // One refusal, one denial counted: the error path does not ask about an audited denial again. + let denied: u64 = counts.counts().iter().map(|c| c.count).sum(); + assert_eq!(denied, 1); assert_eq!(tree(&outside), before, "nothing changed outside the root"); // Files written before the swap are in the renamed directory, inside // the root; the link now at `out` cannot be followed to remove them. diff --git a/docs/reference/metrics-v1.md b/docs/reference/metrics-v1.md index c771b8e7e..a564513c5 100644 --- a/docs/reference/metrics-v1.md +++ b/docs/reference/metrics-v1.md @@ -43,5 +43,5 @@ The `denied` value of `status` is reserved: access-policy denials fail before ex | `krishiv.io.request.duration` | `krishiv_io_request_duration_seconds` | histogram | `s` | op; store_kind | no source yet | No source yet: the I/O layer keeps no per-request durations, so this entry emits nothing until they land. | | `krishiv.exec.driver.stalls` | `krishiv_exec_driver_stalls_total` | counter | `1` | — | query fold | Driver polls longer than the stall threshold, summed over the query's drivers. | | `krishiv.build.info` | `krishiv_build_info` | gauge | `1` | version; git_sha | scrape | The build: always 1, labelled with its version and commit. | -| `krishiv.auth.failed` | `krishiv_auth_failed_total` | counter | `{request}` | surface(connect, admin, metrics, ui); method(token, oidc, mtls, throttle, none) | scrape | Requests to `krishiv server` refused as unauthenticated (a missing, malformed, wrong or expired credential) or throttled, by listener and the method tried. Zero series without authentication configured. | -| `krishiv.auth.denied` | `krishiv_auth_denied_total` | counter | `{request}` | surface(connect, admin, metrics, ui); privilege(SELECT, MODIFY, CREATE, DROP, MONITOR, USAGE, BROWSE, OPERATE, MANAGE, IMPERSONATE) | scrape | Access checks of `krishiv server` callers refused with `KRISHIV_INSUFFICIENT_PERMISSIONS`, by listener and the privilege asked for. | +| `krishiv.auth.failed` | `krishiv_auth_failed_total` | counter | `{request}` | surface(connect, admin, metrics, ui); method(token, oidc, mtls, throttle, none) | scrape | Requests to `krishiv server` refused as unauthenticated (a missing, malformed, wrong or expired credential) or throttled, by listener and the method tried. Served only by `krishiv server` with authentication configured. | +| `krishiv.auth.denied` | `krishiv_auth_denied_total` | counter | `{request}` | surface(connect, admin, metrics, ui); privilege(SELECT, MODIFY, CREATE, DROP, MONITOR, USAGE, BROWSE, OPERATE, MANAGE, IMPERSONATE) | scrape | Access checks of `krishiv server` callers refused with `KRISHIV_INSUFFICIENT_PERMISSIONS`, by listener and the privilege asked for. Served only by `krishiv server` with authentication configured. | From a2145b38395a532cb4c6f7569d7b5b226956aa2b Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Sat, 14 Nov 2026 03:09:50 +0000 Subject: [PATCH 34/47] =?UTF-8?q?session,=20connect-server:=20the=20client?= =?UTF-8?q?'s=20user=5Fid=20recorded=20unverified=20in=20system.sessions?= =?UTF-8?q?=20and=20denial=20records;=20viewers=20read=20their=20own=20sys?= =?UTF-8?q?tem.sessions=20rows=20(ADR=200046=20=C2=A75,=20threat=20model?= =?UTF-8?q?=20T-R3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-connect-server/CLAUDE.md | 3 +- crates/krishiv-connect-server/src/manager.rs | 8 +++ crates/krishiv-connect-server/tests/auth.rs | 57 ++++++++++++++++++++ crates/krishiv-session/CLAUDE.md | 2 +- crates/krishiv-session/src/access.rs | 3 ++ crates/krishiv-session/src/auth/roles.rs | 10 ++-- crates/krishiv-session/src/sessions.rs | 9 ++++ crates/krishiv-session/tests/roles_matrix.rs | 1 + 8 files changed, 86 insertions(+), 7 deletions(-) diff --git a/crates/krishiv-connect-server/CLAUDE.md b/crates/krishiv-connect-server/CLAUDE.md index 0e4b02755..473cd4fed 100644 --- a/crates/krishiv-connect-server/CLAUDE.md +++ b/crates/krishiv-connect-server/CLAUDE.md @@ -55,7 +55,8 @@ Paths are relative to this crate. classes, else the Python facade's class/SQLSTATE rules); `KRISHIV_SESSION_LIMIT` and `KRISHIV_OPERATION_LIMIT` are `RESOURCE_EXHAUSTED` - `src/manager.rs` `SessionManager` (Spark's `SparkConnectSessionManager`; its factory gets the `Authenticated` - caller): get-or-create / lookup / release, tombstones + caller; a new session records transport `connect`, the client's `user_id` (unverified, ADR 0046 §5) and its + server-side id for `system.sessions`): get-or-create / lookup / release, tombstones (LRU, no expiry), `SESSION_CHANGED` on a changed server-side id, the idle sweep (skips a session running a query or holding a live operation; `busy()` is what shutdown waits for), `krishiv.connect.maxSessions` / `maxSessionsPerPrincipal` diff --git a/crates/krishiv-connect-server/src/manager.rs b/crates/krishiv-connect-server/src/manager.rs index 7aeba8543..fe7c8e1bc 100644 --- a/crates/krishiv-connect-server/src/manager.rs +++ b/crates/krishiv-connect-server/src/manager.rs @@ -352,6 +352,14 @@ impl SessionManager { // Built without the lock; a racing create of the same key wins and // this one is closed unused. let session = (self.factory)(caller)?; + // `system.sessions` shows the client's `user_id` as reported (ADR 0046 §5: recorded, + // unverified, never the identity). + session.set_client_info( + krishiv_session::Transport::Connect, + None, + Some(client_user_id), + Some(session.id()), + ); let entry = Arc::new(SessionEntry { session, caller: caller.clone(), diff --git a/crates/krishiv-connect-server/tests/auth.rs b/crates/krishiv-connect-server/tests/auth.rs index 75124df90..d3198aa85 100644 --- a/crates/krishiv-connect-server/tests/auth.rs +++ b/crates/krishiv-connect-server/tests/auth.rs @@ -509,3 +509,60 @@ async fn authentication_comes_before_the_body_is_decoded() { assert_ne!(s.code(), Code::Unauthenticated, "{s:?}"); assert_ne!(s.code(), Code::Ok); } + +/// ADR 0046 §5 (threat model T-R3): the client's `UserContext.user_id` is never the identity, +/// but its session's `system.sessions` row records it as the client reported it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_clients_user_id_is_recorded_not_trusted() { + use arrow_array::Array as _; + let run = start().await; + // The helper's requests say `user_id = "dana"`; the token is carol's (a viewer, who sees + // her own rows of `system.sessions`). + let responses = execute( + &run.channel, + sql("SELECT principal, client_user_id, transport FROM system.sessions"), + Some(VIEWER), + ) + .await + .expect("a viewer reads her own sessions"); + let rows: Vec<(String, String, String)> = responses + .iter() + .filter_map(|r| match &r.response_type { + Some(pb::execute_plan_response::ResponseType::ArrowBatch(b)) => Some(b), + _ => None, + }) + .flat_map(|b| { + arrow_ipc::reader::StreamReader::try_new(std::io::Cursor::new(b.data.clone()), None) + .expect("ipc") + .collect::, _>>() + .expect("batches") + }) + .flat_map(|batch| { + let text = |i: usize| { + batch + .column(i) + .as_any() + .downcast_ref::() + .expect("string column") + .clone() + }; + let (p, u, t) = (text(0), text(1), text(2)); + (0..batch.num_rows()) + .map(|r| { + let get = |a: &arrow_array::StringArray| { + if a.is_null(r) { + String::new() + } else { + a.value(r).to_owned() + } + }; + (get(&p), get(&u), get(&t)) + }) + .collect::>() + }) + .collect(); + assert_eq!( + rows, + [("carol".to_owned(), "dana".to_owned(), "connect".to_owned())] + ); +} diff --git a/crates/krishiv-session/CLAUDE.md b/crates/krishiv-session/CLAUDE.md index eb8120094..037af8a11 100644 --- a/crates/krishiv-session/CLAUDE.md +++ b/crates/krishiv-session/CLAUDE.md @@ -27,7 +27,7 @@ Paths are relative to this crate. - `src/admission.rs` the admission seam (E5): `AdmissionController::admit(&QueryRequest) -> Permit` asked once per query in `run_on_query_logged`; default `Unlimited`; a waiting `admit` shows the query `queued` in `system.queries`, a refusal closes the row `failed`, a drop while queued `cancelled`; the permit (slot, optional per-query memory budget) lives in the query's `QueryFold` and releases on drop; `tests/admission.rs` - `src/resource_groups/` (P3-T003, design `P3-T003-workload-management.md`) `ResourceGroups`, an `AdmissionController` over a Trino-shaped group file (`config.rs`: subset only, unknown/CPU/template keys are `KRISHIV_RESOURCE_GROUP_CONFIG_INVALID`), selectors (`select.rs`: user, userGroup, source, clientTags `k`/`k=v`, queryType; first match, else `defaultGroup`), and the tree with its dispatcher (`tree.rs`: one `Mutex`, nothing sent or dropped under it; `weighted_fair` by running/weight, `query_priority`; hard concurrency, `maxQueued` refusal `KRISHIV_RESOURCE_GROUP_QUEUE_FULL`, soft memory from `GroupSlot::set_reserved`, scan quota windows charged at slot drop, `KRISHIV_RESOURCE_GROUP_QUOTA_EXHAUSTED`); `Permit::slot::()` reaches the guard. Not wired yet: the config key, `source`/`statement_class` on the session's `QueryRequest`, `system.queries` columns, memory-pool priority - `src/access.rs` `StatementInfo` / `Session::statement_info` (class + read securables with columns), write/DDL checks (`authorize_statement`), `Session::authorize_privilege` (one privilege on one securable outside a statement, audited: the Connect request classes of ADR 0046 §3b), the `krishiv::audit` denial record, `PathPrefixPolicy` (local roots; object-store prefixes by store and whole key segments, `with_object_prefixes`; `from_config` is the server-mode confinement of ADR 0046 §3a from `krishiv.io.local.allowedRoots` / `krishiv.io.objectStore.allowedPrefixes`, default empty, every role, absolute roots; Azure prefixes name their account; with `allowedRoots` set the runtime's local store refuses listings that follow a link out of the roots, `SettledLocalFileSystem::confined`; `tests/path_confinement.rs`). Server-scoped store options (P3-T067b; `krishiv_io::is_server_scoped_option`: every `fs.*` key but an inline key, secret, token or SAS) need `Manage` on `System("cluster")` per read (`bind_files`, table `WITH`), per write (`authorize_write_path`; a table's own stored options are not asked again), on CREATE TABLE `OPTIONS` / `SET SERDEPROPERTIES`, on SQL `SET`, and per query while the session changed one from its builder config (`ConfStore::store_settings_changed`); `tests/server_egress.rs` -- `src/auth/` (P3-T002, design `design/P3-T002-authentication.md`, ADR 0046) server authentication: `Role` (viewer < operator < admin), `Credentials` / `Authenticated` / `AuthFailure` (Spark's two `UNAUTHENTICATED` messages), the `Authenticator` trait and `ServerAuth` (throttle, then static tokens; one `krishiv::auth` warn record and one `(surface, method)` counter per failure, never the credential; `ServerAuth::metrics` gives `krishiv.auth.failed` / `krishiv.auth.denied` as counter samples for `/metrics`); `denials.rs` `DeniedCounts`, the server's `(surface, privilege)` denial counters (ADR 0046 §8), shared by its guards and its sessions (`Session::count_denials`: every `KRISHIV_INSUFFICIENT_PERMISSIONS` the session's `log_denial` writes is counted, by the error's `privilege` parameter); `config.rs` `AuthConfig::from_settings` (`krishiv.auth.tokens..{secret,principal,kind,role}`, `spark.connect.authenticate.token` = one admin token named `spark`, `krishiv.auth.failureThrottle.*`; unknown `krishiv.auth.*` keys refused); `secret_ref.rs` `env:`/`file:` references (a file readable by group or others refuses startup); `tokens.rs` SHA-256 digests compared in constant time against every entry (secrets >= 32 bytes, no two tokens share one); `throttle.rs` per-address failure blocks, bounded at 65,536 addresses; `mtls.rs` client certificates (`krishiv.auth.clientCertificates` off/optional/required, `clientCa`, `mtls.principalFrom` san-uri/san-dns/cn: exactly one value of that kind or refused, `mtls.userPrincipals`; roles from `krishiv.auth.roles.{viewer,operator,admin}` lists via `config.rs` `RoleLists`; a bearer token next to a certificate must name the same principal; `required` never falls back to a token); `der.rs` `CertNames::from_der`, the bounded no-recursion DER walk of the leaf's subject CN and DNS/URI SANs (fuzz target `fuzz/fuzz_targets/cert_names.rs` includes it by path); `tls_config.rs` `TlsConfig` (`krishiv.server.tls.{certFile,keyFile,minVersion}`) and `check_listener` (startup refusals: non-loopback without auth, non-loopback tokens without TLS, client certificates without TLS); `tls.rs` (feature `tls`) `ServerTls`, the rustls `ServerConfig` on aws-lc-rs with h2/http1.1 ALPN, WebPKI client verifier, a key file checked like a secret file and `reload()` swapping the certified key (a failed reload keeps the old one); `mtls_tests.rs` with in-memory handshakes against `tests/fixtures/tls/`; `oidc_config.rs` `OidcConfig` (`krishiv.auth.oidc.{issuer,audience,jwksUri,algorithms,principalClaim,roleClaim,clockSkew,jwksRefresh,allowInsecureIssuer}` and `krishiv.auth.oidc.roles.{viewer,operator,admin}`, the role-claim values by role, a namespace separate from the certificate names of `krishiv.auth.roles.*`; refused without feature `oidc`; locations https only unless the test flag, with a host, no userinfo, no fragment); `jose.rs` the pure JWS/JWK set/discovery parsers (16 KiB token cap, canonical unpadded base64url, duplicate JSON members refused, `alg` only RS256/384/512 and ES256/384, `crit` refused, header key members ignored; `check_claims`: `exp`/`nbf` with skew, exact `iss`, `aud`, principal and groups; fuzz target `fuzz/fuzz_targets/jwt_parse.rs` includes it by path); `oidc.rs` (feature `oidc`) signatures on aws-lc-rs (no `none`, no HMAC), `JwksCache` (an unknown `kid` asks for one fetch per minute, the first such request waits for it, others fail at once; a token without `kid` needs exactly one fitting key; a failed fetch or a set with no usable key keeps the keys; no keys at all is `Unavailable`) and the `krishiv-jwks` fetch thread (`HttpKeySource`: blocking reqwest, no redirects, 1 MiB cap, one 10 s deadline per fetch); the role of an OIDC principal is the highest any of its role-claim values has in the OIDC role lists; only an invalid token counts toward the throttle; `jose_tests.rs` (every build), `oidc_tests.rs` with keys generated per run, `oidc_http_tests.rs` against a loopback HTTP server; `roles.rs` `RolePolicy`, the ADR 0046 §3 matrix as an `AccessPolicy` (viewer: `Browse`, own `system.*` rows, `/metrics`; operator: `Select`, `Usage`, `Monitor`, `Operate`; admin: `Modify`/`Create`/`Drop`/`Manage`; client code at `krishiv.auth.udf.minRole`; `Impersonate` never; no role denies all), `tests/roles_matrix.rs`. Metadata statements need `Browse` (`authorize` falls back to `Select` for providers that know only `Select`); `ADMIN`-class statements need `Manage` on `System("cluster")` +- `src/auth/` (P3-T002, design `design/P3-T002-authentication.md`, ADR 0046) server authentication: `Role` (viewer < operator < admin), `Credentials` / `Authenticated` / `AuthFailure` (Spark's two `UNAUTHENTICATED` messages), the `Authenticator` trait and `ServerAuth` (throttle, then static tokens; one `krishiv::auth` warn record and one `(surface, method)` counter per failure, never the credential; `ServerAuth::metrics` gives `krishiv.auth.failed` / `krishiv.auth.denied` as counter samples for `/metrics`); `denials.rs` `DeniedCounts`, the server's `(surface, privilege)` denial counters (ADR 0046 §8), shared by its guards and its sessions (`Session::count_denials`: every `KRISHIV_INSUFFICIENT_PERMISSIONS` the session's `log_denial` writes is counted, by the error's `privilege` parameter; the denial record also carries `client_user_id_unverified`, ADR 0046 §5); `config.rs` `AuthConfig::from_settings` (`krishiv.auth.tokens..{secret,principal,kind,role}`, `spark.connect.authenticate.token` = one admin token named `spark`, `krishiv.auth.failureThrottle.*`; unknown `krishiv.auth.*` keys refused); `secret_ref.rs` `env:`/`file:` references (a file readable by group or others refuses startup); `tokens.rs` SHA-256 digests compared in constant time against every entry (secrets >= 32 bytes, no two tokens share one); `throttle.rs` per-address failure blocks, bounded at 65,536 addresses; `mtls.rs` client certificates (`krishiv.auth.clientCertificates` off/optional/required, `clientCa`, `mtls.principalFrom` san-uri/san-dns/cn: exactly one value of that kind or refused, `mtls.userPrincipals`; roles from `krishiv.auth.roles.{viewer,operator,admin}` lists via `config.rs` `RoleLists`; a bearer token next to a certificate must name the same principal; `required` never falls back to a token); `der.rs` `CertNames::from_der`, the bounded no-recursion DER walk of the leaf's subject CN and DNS/URI SANs (fuzz target `fuzz/fuzz_targets/cert_names.rs` includes it by path); `tls_config.rs` `TlsConfig` (`krishiv.server.tls.{certFile,keyFile,minVersion}`) and `check_listener` (startup refusals: non-loopback without auth, non-loopback tokens without TLS, client certificates without TLS); `tls.rs` (feature `tls`) `ServerTls`, the rustls `ServerConfig` on aws-lc-rs with h2/http1.1 ALPN, WebPKI client verifier, a key file checked like a secret file and `reload()` swapping the certified key (a failed reload keeps the old one); `mtls_tests.rs` with in-memory handshakes against `tests/fixtures/tls/`; `oidc_config.rs` `OidcConfig` (`krishiv.auth.oidc.{issuer,audience,jwksUri,algorithms,principalClaim,roleClaim,clockSkew,jwksRefresh,allowInsecureIssuer}` and `krishiv.auth.oidc.roles.{viewer,operator,admin}`, the role-claim values by role, a namespace separate from the certificate names of `krishiv.auth.roles.*`; refused without feature `oidc`; locations https only unless the test flag, with a host, no userinfo, no fragment); `jose.rs` the pure JWS/JWK set/discovery parsers (16 KiB token cap, canonical unpadded base64url, duplicate JSON members refused, `alg` only RS256/384/512 and ES256/384, `crit` refused, header key members ignored; `check_claims`: `exp`/`nbf` with skew, exact `iss`, `aud`, principal and groups; fuzz target `fuzz/fuzz_targets/jwt_parse.rs` includes it by path); `oidc.rs` (feature `oidc`) signatures on aws-lc-rs (no `none`, no HMAC), `JwksCache` (an unknown `kid` asks for one fetch per minute, the first such request waits for it, others fail at once; a token without `kid` needs exactly one fitting key; a failed fetch or a set with no usable key keeps the keys; no keys at all is `Unavailable`) and the `krishiv-jwks` fetch thread (`HttpKeySource`: blocking reqwest, no redirects, 1 MiB cap, one 10 s deadline per fetch); the role of an OIDC principal is the highest any of its role-claim values has in the OIDC role lists; only an invalid token counts toward the throttle; `jose_tests.rs` (every build), `oidc_tests.rs` with keys generated per run, `oidc_http_tests.rs` against a loopback HTTP server; `roles.rs` `RolePolicy`, the ADR 0046 §3 matrix as an `AccessPolicy` (viewer: `Browse`, own `system.*` rows (`queries`, `sessions`), `/metrics`; operator: `Select`, `Usage`, `Monitor`, `Operate`; admin: `Modify`/`Create`/`Drop`/`Manage`; client code at `krishiv.auth.udf.minRole`; `Impersonate` never; no role denies all), `tests/roles_matrix.rs`. Metadata statements need `Browse` (`authorize` falls back to `Select` for providers that know only `Select`); `ADMIN`-class statements need `Manage` on `System("cluster")` - `src/partitions.rs` partitioned and bucketed tables: the layout `CREATE TABLE` stores (partition columns last), the partitions the catalog tracks (`ADD PARTITION`, `SET LOCATION` and static `INSERT` values cast to the column types, `DROP PARTITION` of a managed table: log first, then the directories; each partitioned table has a read/write lock (`InMemoryCatalogStore::partition_ddl` / `partition_write`, keyed by location, weak entries): `RENAME`/`DROP PARTITION`, `MSCK REPAIR` and `TRUNCATE` hold it exclusively (across their log commit retries) and re-read the entry under it, an `INSERT` holds it shared from its write to its partition registration; external tables refused, registered after `INSERT`/CTAS), `DESCRIBE ... PARTITION`, `SHOW PARTITIONS`, `ANALYZE ... PARTITION`; `tests/partitioned_tables.rs` - `src/column_stats.rs` `ANALYZE TABLE .. FOR COLUMNS` (`CommandUtils.statExprs`): one aggregate over the table, kept in `CatalogTable::column_stats` (the catalog is in memory, no on-disk format), printed by `DESCRIBE EXTENDED t col`; a write, `TRUNCATE`, `ADD PARTITION`, `DROP PARTITION` or `SET LOCATION` drops them (`updateTableStats`); published only to the entry the aggregate read (`CatalogTable::incarnation`, set by the store at create, kept by `replace_table`), so a table dropped and created again meanwhile gets none, nor one whose data changed after it was read (`CatalogTable::data_version`, moved by `data_changed`, which every stats-clearing change calls); `MSCK REPAIR` keeps them, as Spark 4.2.0 does; `tests/char_varchar_write.rs` - `src/table_commands/files.rs` a managed table's directory: `authorize_path` (the policy per privilege), `deletable_dir` (inside the root it was created under, no links, `MODIFY`), `delete_table_files`, `remove_dir` diff --git a/crates/krishiv-session/src/access.rs b/crates/krishiv-session/src/access.rs index 1c21a13f7..b47f638aa 100644 --- a/crates/krishiv-session/src/access.rs +++ b/crates/krishiv-session/src/access.rs @@ -176,9 +176,12 @@ fn log_denial(session: &Session, class: StatementClass, e: &KrishivError) { { counts.record_named(*surface, privilege); } + // ADR 0046 §5: the client's own `user_id` is recorded, marked unverified, never used. + let client_user_id = session.state.activity.client_user_id(); tracing::warn!( target: "krishiv::audit", principal = %session.state.principal.name, + client_user_id_unverified = client_user_id.as_deref(), statement_class = class.name(), error_class = e.error_class().name(), denial = %e, diff --git a/crates/krishiv-session/src/auth/roles.rs b/crates/krishiv-session/src/auth/roles.rs index 89b8968cf..09bd38e24 100644 --- a/crates/krishiv-session/src/auth/roles.rs +++ b/crates/krishiv-session/src/auth/roles.rs @@ -10,7 +10,7 @@ //! | Privilege | viewer | operator | admin | //! |---|---|---|---| //! | `Browse` (metadata) | yes | yes | yes | -//! | `Select` on `system.queries` (own rows; others' need `Monitor`), `capabilities`, `metrics` | yes | yes | yes | +//! | `Select` on `system.queries`, `system.sessions` (own rows; others' need `Monitor`), `capabilities`, `metrics` | yes | yes | yes | //! | `Select` on the other `system.*` tables (`memory`, `operators`, `caches`) | no | yes | yes | //! | `Select` on anything else (tables, views, functions, paths) | no (T062 grants) | yes | yes | //! | `Usage` on a secret or connection | no | yes | yes | @@ -37,10 +37,10 @@ pub(crate) const SYSTEM_UDF: &str = "udf"; pub(crate) const SYSTEM_METRICS: &str = "metrics"; /// The reserved `system` catalog. const SYSTEM_CATALOG: &str = "system"; -/// The `system` tables a viewer reads: `queries` shows only the principal's own rows without -/// `Monitor`, `capabilities` and `metrics` hold no principal's data (ADR 0046 §3, §3c). The -/// others (`memory`, `operators`, `caches`) name other principals' work: operator. -const VIEWER_SYSTEM_TABLES: [&str; 3] = ["queries", "capabilities", "metrics"]; +/// The `system` tables a viewer reads: `queries` and `sessions` show only the principal's own +/// rows without `Monitor`, `capabilities` and `metrics` hold no principal's data (ADR 0046 §3, +/// §3c). The others (`memory`, `operators`, `caches`) name other principals' work: operator. +const VIEWER_SYSTEM_TABLES: [&str; 4] = ["queries", "sessions", "capabilities", "metrics"]; /// The ADR 0046 role matrix for one session's principal. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/krishiv-session/src/sessions.rs b/crates/krishiv-session/src/sessions.rs index b9c7f514b..86b970045 100644 --- a/crates/krishiv-session/src/sessions.rs +++ b/crates/krishiv-session/src/sessions.rs @@ -161,6 +161,15 @@ impl Activity { .store(ms.unwrap_or(NO_TIMEOUT), Ordering::Relaxed); } + /// The user id the client reported (client-asserted, never an identity). + pub(crate) fn client_user_id(&self) -> Option { + self.client + .lock() + .unwrap_or_else(PoisonError::into_inner) + .client_user_id + .clone() + } + /// Keeps the first non-empty client type and user id seen, and the latest server session id. pub(crate) fn set_client( &self, diff --git a/crates/krishiv-session/tests/roles_matrix.rs b/crates/krishiv-session/tests/roles_matrix.rs index d867570fd..d2a18d0ae 100644 --- a/crates/krishiv-session/tests/roles_matrix.rs +++ b/crates/krishiv-session/tests/roles_matrix.rs @@ -72,6 +72,7 @@ const MATRIX: &[(&str, Role)] = &[ ("SELECT count(*) FROM system.queries", Role::Viewer), ("SELECT count(*) FROM SYSTEM.QUERIES", Role::Viewer), ("SELECT count(*) FROM system.capabilities", Role::Viewer), + ("SELECT count(*) FROM system.sessions", Role::Viewer), // A system table's schema is metadata, even where its rows are not (Browse, not Select). ("DESCRIBE system.memory", Role::Viewer), ("SHOW COLUMNS IN system.memory", Role::Viewer), From 354c36d560f137065864a108d3fbede7254a9910 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Sat, 14 Nov 2026 03:11:26 +0000 Subject: [PATCH 35/47] docs: deploy guide: server authentication, roles, TLS, startup refusals, the auth counters, and logs are not an audit trail (threat model T-R1) Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- docs/guides/deploy.md | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/guides/deploy.md b/docs/guides/deploy.md index 7a79f4682..0c2279a08 100644 --- a/docs/guides/deploy.md +++ b/docs/guides/deploy.md @@ -27,10 +27,37 @@ Nothing has been published to PyPI or crates.io yet. Until the first release, bu One process, started with `krishiv server` (a build with the `server` feature), runs the engine and serves Spark Connect; clients connect to it with a Spark Connect client such as PySpark. It listens on `127.0.0.1` port `15002` by default (`krishiv.connect.port`, see -[configuration](../reference/config/index.md)). Authentication is not available yet, so keep it on loopback and reach it through a tunnel or proxy; the server refuses a -non-loopback address until authentication exists. Where it differs from Spark see +[configuration](../reference/config/index.md)). Where it differs from Spark see [known differences](../concepts/known-differences.md#krishiv-server-spark-connect). +### Authentication, roles and TLS + +Without authentication settings the server binds loopback only, and the admin port (`/metrics`, the web UI) prints a +random token at start. To serve another address, configure at least one method with `--conf` (these settings are +read by the server itself and never become session configuration): + +- **Tokens:** `krishiv.auth.tokens..secret` (an `env:` or `file:` reference; a file readable by group or others + is refused), `.principal`, `.role` (`viewer`, `operator` or `admin`). Spark's `spark.connect.authenticate.token` is + one admin token. +- **OIDC:** `krishiv.auth.oidc.issuer`, `audience` and the role lists `krishiv.auth.oidc.roles.*`. +- **Client certificates:** `krishiv.auth.clientCertificates` (`optional` or `required`), `krishiv.auth.clientCa`, + `krishiv.auth.mtls.principalFrom`, and the role lists `krishiv.auth.roles.*`. + +A viewer sees metadata and its own queries and sessions, an operator also reads data and acts on others' running +work, an admin may also write, create, drop and change the cluster. A caller with no role is refused. + +TLS: `krishiv.server.tls.certFile` and `keyFile` (the key file must not be readable by group or others) turn on TLS for +both ports; clients then connect with `sc://host:15002/;use_ssl=true` and `https://`. The server refuses to start +when the settings do not fit: a non-loopback address without an authentication method, a token or OIDC on a +non-loopback address without TLS, or client certificates without TLS. On Unix, `SIGHUP` reloads the certificate and +key; new connections get the new ones, open connections keep theirs, and a file that fails to load leaves the old +pair serving. + +Every refused request is counted on `/metrics`: `krishiv_auth_failed_total` (unauthenticated or throttled, by port and +method) and `krishiv_auth_denied_total` (permission denied, by port and privilege). The `krishiv::auth` and +`krishiv::audit` log records describe each refusal, but logs are rate limited (`KRISHIV_LOG_RATE`) and best effort: +they are not an audit trail. Use the counters to see how many refusals happened. + ## Distributed A scheduler and workers run one query across machines. It is in development; the mode is not available yet. From c31bfc060f39172e3a9f86a20b38a22503b08a53 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:23:29 +0000 Subject: [PATCH 36/47] =?UTF-8?q?session,=20connect-server:=20SET=20and=20?= =?UTF-8?q?RESET=20of=20a=20key=20settable=20by=20admin=20need=20Manage=20?= =?UTF-8?q?on=20the=20cluster=20through=20the=20session's=20policy=20(ADR?= =?UTF-8?q?=200046=20=C2=A73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-connect-server/src/config.rs | 8 ++++---- crates/krishiv-session/CLAUDE.md | 2 +- crates/krishiv-session/src/command.rs | 8 ++++++-- crates/krishiv-session/src/session/ops.rs | 21 +++++++++++++++++--- crates/krishiv-session/tests/roles_matrix.rs | 4 ++++ 5 files changed, 33 insertions(+), 10 deletions(-) diff --git a/crates/krishiv-connect-server/src/config.rs b/crates/krishiv-connect-server/src/config.rs index 71da6ed08..1d7d95df7 100644 --- a/crates/krishiv-connect-server/src/config.rs +++ b/crates/krishiv-connect-server/src/config.rs @@ -60,10 +60,10 @@ pub(crate) enum ConfigRole { /// with none is a viewer. With authentication off every request is the local user on a /// loopback listener (design P3-T001 §3.6), which is the admin. /// -/// TODO(P3-T002 follow-up): move [`check_settable`] into the session's `AccessPolicy` path, -/// so SQL `SET` (`Session::sql`) and Connect's `SqlCommand` (T012) take the same gate as this -/// RPC (ADR 0046 rejects listener-local checks), and hide `Startup` and `Admin` keys from -/// `get_all` for viewers and operators. +/// SQL `SET` takes the same gate through the session's policy (`Manage` on the cluster for an +/// `admin` key). TODO(P3-T002 follow-up): have this RPC ask the session's policy too instead +/// of [`check_settable`] (ADR 0046 rejects listener-local checks), and hide `Startup` and +/// `Admin` keys from `get_all` for viewers and operators. #[must_use] pub(crate) fn role_of(caller: &Authenticated) -> ConfigRole { match (caller.method, caller.role) { diff --git a/crates/krishiv-session/CLAUDE.md b/crates/krishiv-session/CLAUDE.md index 037af8a11..3a8771c85 100644 --- a/crates/krishiv-session/CLAUDE.md +++ b/crates/krishiv-session/CLAUDE.md @@ -27,7 +27,7 @@ Paths are relative to this crate. - `src/admission.rs` the admission seam (E5): `AdmissionController::admit(&QueryRequest) -> Permit` asked once per query in `run_on_query_logged`; default `Unlimited`; a waiting `admit` shows the query `queued` in `system.queries`, a refusal closes the row `failed`, a drop while queued `cancelled`; the permit (slot, optional per-query memory budget) lives in the query's `QueryFold` and releases on drop; `tests/admission.rs` - `src/resource_groups/` (P3-T003, design `P3-T003-workload-management.md`) `ResourceGroups`, an `AdmissionController` over a Trino-shaped group file (`config.rs`: subset only, unknown/CPU/template keys are `KRISHIV_RESOURCE_GROUP_CONFIG_INVALID`), selectors (`select.rs`: user, userGroup, source, clientTags `k`/`k=v`, queryType; first match, else `defaultGroup`), and the tree with its dispatcher (`tree.rs`: one `Mutex`, nothing sent or dropped under it; `weighted_fair` by running/weight, `query_priority`; hard concurrency, `maxQueued` refusal `KRISHIV_RESOURCE_GROUP_QUEUE_FULL`, soft memory from `GroupSlot::set_reserved`, scan quota windows charged at slot drop, `KRISHIV_RESOURCE_GROUP_QUOTA_EXHAUSTED`); `Permit::slot::()` reaches the guard. Not wired yet: the config key, `source`/`statement_class` on the session's `QueryRequest`, `system.queries` columns, memory-pool priority - `src/access.rs` `StatementInfo` / `Session::statement_info` (class + read securables with columns), write/DDL checks (`authorize_statement`), `Session::authorize_privilege` (one privilege on one securable outside a statement, audited: the Connect request classes of ADR 0046 §3b), the `krishiv::audit` denial record, `PathPrefixPolicy` (local roots; object-store prefixes by store and whole key segments, `with_object_prefixes`; `from_config` is the server-mode confinement of ADR 0046 §3a from `krishiv.io.local.allowedRoots` / `krishiv.io.objectStore.allowedPrefixes`, default empty, every role, absolute roots; Azure prefixes name their account; with `allowedRoots` set the runtime's local store refuses listings that follow a link out of the roots, `SettledLocalFileSystem::confined`; `tests/path_confinement.rs`). Server-scoped store options (P3-T067b; `krishiv_io::is_server_scoped_option`: every `fs.*` key but an inline key, secret, token or SAS) need `Manage` on `System("cluster")` per read (`bind_files`, table `WITH`), per write (`authorize_write_path`; a table's own stored options are not asked again), on CREATE TABLE `OPTIONS` / `SET SERDEPROPERTIES`, on SQL `SET`, and per query while the session changed one from its builder config (`ConfStore::store_settings_changed`); `tests/server_egress.rs` -- `src/auth/` (P3-T002, design `design/P3-T002-authentication.md`, ADR 0046) server authentication: `Role` (viewer < operator < admin), `Credentials` / `Authenticated` / `AuthFailure` (Spark's two `UNAUTHENTICATED` messages), the `Authenticator` trait and `ServerAuth` (throttle, then static tokens; one `krishiv::auth` warn record and one `(surface, method)` counter per failure, never the credential; `ServerAuth::metrics` gives `krishiv.auth.failed` / `krishiv.auth.denied` as counter samples for `/metrics`); `denials.rs` `DeniedCounts`, the server's `(surface, privilege)` denial counters (ADR 0046 §8), shared by its guards and its sessions (`Session::count_denials`: every `KRISHIV_INSUFFICIENT_PERMISSIONS` the session's `log_denial` writes is counted, by the error's `privilege` parameter; the denial record also carries `client_user_id_unverified`, ADR 0046 §5); `config.rs` `AuthConfig::from_settings` (`krishiv.auth.tokens..{secret,principal,kind,role}`, `spark.connect.authenticate.token` = one admin token named `spark`, `krishiv.auth.failureThrottle.*`; unknown `krishiv.auth.*` keys refused); `secret_ref.rs` `env:`/`file:` references (a file readable by group or others refuses startup); `tokens.rs` SHA-256 digests compared in constant time against every entry (secrets >= 32 bytes, no two tokens share one); `throttle.rs` per-address failure blocks, bounded at 65,536 addresses; `mtls.rs` client certificates (`krishiv.auth.clientCertificates` off/optional/required, `clientCa`, `mtls.principalFrom` san-uri/san-dns/cn: exactly one value of that kind or refused, `mtls.userPrincipals`; roles from `krishiv.auth.roles.{viewer,operator,admin}` lists via `config.rs` `RoleLists`; a bearer token next to a certificate must name the same principal; `required` never falls back to a token); `der.rs` `CertNames::from_der`, the bounded no-recursion DER walk of the leaf's subject CN and DNS/URI SANs (fuzz target `fuzz/fuzz_targets/cert_names.rs` includes it by path); `tls_config.rs` `TlsConfig` (`krishiv.server.tls.{certFile,keyFile,minVersion}`) and `check_listener` (startup refusals: non-loopback without auth, non-loopback tokens without TLS, client certificates without TLS); `tls.rs` (feature `tls`) `ServerTls`, the rustls `ServerConfig` on aws-lc-rs with h2/http1.1 ALPN, WebPKI client verifier, a key file checked like a secret file and `reload()` swapping the certified key (a failed reload keeps the old one); `mtls_tests.rs` with in-memory handshakes against `tests/fixtures/tls/`; `oidc_config.rs` `OidcConfig` (`krishiv.auth.oidc.{issuer,audience,jwksUri,algorithms,principalClaim,roleClaim,clockSkew,jwksRefresh,allowInsecureIssuer}` and `krishiv.auth.oidc.roles.{viewer,operator,admin}`, the role-claim values by role, a namespace separate from the certificate names of `krishiv.auth.roles.*`; refused without feature `oidc`; locations https only unless the test flag, with a host, no userinfo, no fragment); `jose.rs` the pure JWS/JWK set/discovery parsers (16 KiB token cap, canonical unpadded base64url, duplicate JSON members refused, `alg` only RS256/384/512 and ES256/384, `crit` refused, header key members ignored; `check_claims`: `exp`/`nbf` with skew, exact `iss`, `aud`, principal and groups; fuzz target `fuzz/fuzz_targets/jwt_parse.rs` includes it by path); `oidc.rs` (feature `oidc`) signatures on aws-lc-rs (no `none`, no HMAC), `JwksCache` (an unknown `kid` asks for one fetch per minute, the first such request waits for it, others fail at once; a token without `kid` needs exactly one fitting key; a failed fetch or a set with no usable key keeps the keys; no keys at all is `Unavailable`) and the `krishiv-jwks` fetch thread (`HttpKeySource`: blocking reqwest, no redirects, 1 MiB cap, one 10 s deadline per fetch); the role of an OIDC principal is the highest any of its role-claim values has in the OIDC role lists; only an invalid token counts toward the throttle; `jose_tests.rs` (every build), `oidc_tests.rs` with keys generated per run, `oidc_http_tests.rs` against a loopback HTTP server; `roles.rs` `RolePolicy`, the ADR 0046 §3 matrix as an `AccessPolicy` (viewer: `Browse`, own `system.*` rows (`queries`, `sessions`), `/metrics`; operator: `Select`, `Usage`, `Monitor`, `Operate`; admin: `Modify`/`Create`/`Drop`/`Manage`; client code at `krishiv.auth.udf.minRole`; `Impersonate` never; no role denies all), `tests/roles_matrix.rs`. Metadata statements need `Browse` (`authorize` falls back to `Select` for providers that know only `Select`); `ADMIN`-class statements need `Manage` on `System("cluster")` +- `src/auth/` (P3-T002, design `design/P3-T002-authentication.md`, ADR 0046) server authentication: `Role` (viewer < operator < admin), `Credentials` / `Authenticated` / `AuthFailure` (Spark's two `UNAUTHENTICATED` messages), the `Authenticator` trait and `ServerAuth` (throttle, then static tokens; one `krishiv::auth` warn record and one `(surface, method)` counter per failure, never the credential; `ServerAuth::metrics` gives `krishiv.auth.failed` / `krishiv.auth.denied` as counter samples for `/metrics`); `denials.rs` `DeniedCounts`, the server's `(surface, privilege)` denial counters (ADR 0046 §8), shared by its guards and its sessions (`Session::count_denials`: every `KRISHIV_INSUFFICIENT_PERMISSIONS` the session's `log_denial` writes is counted, by the error's `privilege` parameter; the denial record also carries `client_user_id_unverified`, ADR 0046 §5); `config.rs` `AuthConfig::from_settings` (`krishiv.auth.tokens..{secret,principal,kind,role}`, `spark.connect.authenticate.token` = one admin token named `spark`, `krishiv.auth.failureThrottle.*`; unknown `krishiv.auth.*` keys refused); `secret_ref.rs` `env:`/`file:` references (a file readable by group or others refuses startup); `tokens.rs` SHA-256 digests compared in constant time against every entry (secrets >= 32 bytes, no two tokens share one); `throttle.rs` per-address failure blocks, bounded at 65,536 addresses; `mtls.rs` client certificates (`krishiv.auth.clientCertificates` off/optional/required, `clientCa`, `mtls.principalFrom` san-uri/san-dns/cn: exactly one value of that kind or refused, `mtls.userPrincipals`; roles from `krishiv.auth.roles.{viewer,operator,admin}` lists via `config.rs` `RoleLists`; a bearer token next to a certificate must name the same principal; `required` never falls back to a token); `der.rs` `CertNames::from_der`, the bounded no-recursion DER walk of the leaf's subject CN and DNS/URI SANs (fuzz target `fuzz/fuzz_targets/cert_names.rs` includes it by path); `tls_config.rs` `TlsConfig` (`krishiv.server.tls.{certFile,keyFile,minVersion}`) and `check_listener` (startup refusals: non-loopback without auth, non-loopback tokens without TLS, client certificates without TLS); `tls.rs` (feature `tls`) `ServerTls`, the rustls `ServerConfig` on aws-lc-rs with h2/http1.1 ALPN, WebPKI client verifier, a key file checked like a secret file and `reload()` swapping the certified key (a failed reload keeps the old one); `mtls_tests.rs` with in-memory handshakes against `tests/fixtures/tls/`; `oidc_config.rs` `OidcConfig` (`krishiv.auth.oidc.{issuer,audience,jwksUri,algorithms,principalClaim,roleClaim,clockSkew,jwksRefresh,allowInsecureIssuer}` and `krishiv.auth.oidc.roles.{viewer,operator,admin}`, the role-claim values by role, a namespace separate from the certificate names of `krishiv.auth.roles.*`; refused without feature `oidc`; locations https only unless the test flag, with a host, no userinfo, no fragment); `jose.rs` the pure JWS/JWK set/discovery parsers (16 KiB token cap, canonical unpadded base64url, duplicate JSON members refused, `alg` only RS256/384/512 and ES256/384, `crit` refused, header key members ignored; `check_claims`: `exp`/`nbf` with skew, exact `iss`, `aud`, principal and groups; fuzz target `fuzz/fuzz_targets/jwt_parse.rs` includes it by path); `oidc.rs` (feature `oidc`) signatures on aws-lc-rs (no `none`, no HMAC), `JwksCache` (an unknown `kid` asks for one fetch per minute, the first such request waits for it, others fail at once; a token without `kid` needs exactly one fitting key; a failed fetch or a set with no usable key keeps the keys; no keys at all is `Unavailable`) and the `krishiv-jwks` fetch thread (`HttpKeySource`: blocking reqwest, no redirects, 1 MiB cap, one 10 s deadline per fetch); the role of an OIDC principal is the highest any of its role-claim values has in the OIDC role lists; only an invalid token counts toward the throttle; `jose_tests.rs` (every build), `oidc_tests.rs` with keys generated per run, `oidc_http_tests.rs` against a loopback HTTP server; `roles.rs` `RolePolicy`, the ADR 0046 §3 matrix as an `AccessPolicy` (viewer: `Browse`, own `system.*` rows (`queries`, `sessions`), `/metrics`; operator: `Select`, `Usage`, `Monitor`, `Operate`; admin: `Modify`/`Create`/`Drop`/`Manage`; client code at `krishiv.auth.udf.minRole`; `Impersonate` never; no role denies all), `tests/roles_matrix.rs`. Metadata statements need `Browse` (`authorize` falls back to `Select` for providers that know only `Select`); `ADMIN`-class statements need `Manage` on `System("cluster")`; `SET` / `RESET key` of a registry key settable by `admin` (its Spark alias too) needs `Manage` on `System("cluster")` (`Session::authorize_set_key`, with the server-scoped store keys) - `src/partitions.rs` partitioned and bucketed tables: the layout `CREATE TABLE` stores (partition columns last), the partitions the catalog tracks (`ADD PARTITION`, `SET LOCATION` and static `INSERT` values cast to the column types, `DROP PARTITION` of a managed table: log first, then the directories; each partitioned table has a read/write lock (`InMemoryCatalogStore::partition_ddl` / `partition_write`, keyed by location, weak entries): `RENAME`/`DROP PARTITION`, `MSCK REPAIR` and `TRUNCATE` hold it exclusively (across their log commit retries) and re-read the entry under it, an `INSERT` holds it shared from its write to its partition registration; external tables refused, registered after `INSERT`/CTAS), `DESCRIBE ... PARTITION`, `SHOW PARTITIONS`, `ANALYZE ... PARTITION`; `tests/partitioned_tables.rs` - `src/column_stats.rs` `ANALYZE TABLE .. FOR COLUMNS` (`CommandUtils.statExprs`): one aggregate over the table, kept in `CatalogTable::column_stats` (the catalog is in memory, no on-disk format), printed by `DESCRIBE EXTENDED t col`; a write, `TRUNCATE`, `ADD PARTITION`, `DROP PARTITION` or `SET LOCATION` drops them (`updateTableStats`); published only to the entry the aggregate read (`CatalogTable::incarnation`, set by the store at create, kept by `replace_table`), so a table dropped and created again meanwhile gets none, nor one whose data changed after it was read (`CatalogTable::data_version`, moved by `data_changed`, which every stats-clearing change calls); `MSCK REPAIR` keeps them, as Spark 4.2.0 does; `tests/char_varchar_write.rs` - `src/table_commands/files.rs` a managed table's directory: `authorize_path` (the policy per privilege), `deletable_dir` (inside the root it was created under, no links, `MODIFY`), `delete_table_files`, `remove_dir` diff --git a/crates/krishiv-session/src/command.rs b/crates/krishiv-session/src/command.rs index adcd29ce0..bb38cc6e9 100644 --- a/crates/krishiv-session/src/command.rs +++ b/crates/krishiv-session/src/command.rs @@ -145,7 +145,7 @@ impl Session { } .into()); } - self.authorize_set_store_key(key).await?; + self.authorize_set_key(key).await?; // The assignment sets through the session configuration, // so validation and error classes are its own; the row // shows the value as written, as in Spark. @@ -210,8 +210,12 @@ impl Session { /// Spark. pub(crate) async fn exec_reset(&self, r: &Reset) -> Result { match &r.key { + // Back to what the session was built with: every key it set passed `SET`'s check. None => self.state.conf.reset(), - Some(key) => self.conf().unset(key)?, + Some(key) => { + self.authorize_set_key(key).await?; + self.conf().unset(key)?; + } } self.empty_frame().await } diff --git a/crates/krishiv-session/src/session/ops.rs b/crates/krishiv-session/src/session/ops.rs index 67e9631a2..76803eb23 100644 --- a/crates/krishiv-session/src/session/ops.rs +++ b/crates/krishiv-session/src/session/ops.rs @@ -105,9 +105,24 @@ impl Session { crate::access::audit(self, class, r) } - /// `SET key = ..` of a server-scoped store key is `Manage` on the cluster (P3-T067b); any - /// other key passes. Asked before the value is stored, so a refused `SET` changes nothing. - pub(crate) async fn authorize_set_store_key(&self, key: &str) -> Result<(), KrishivError> { + /// `SET key = ..` (or `RESET key`) of a key only an administrator may change is `Manage` + /// on the cluster (ADR 0046 §3): a registry key settable by `admin` (its Spark alias too), + /// or a server-scoped store key (P3-T067b). Any other key passes. Asked before the value + /// is stored, so a refused `SET` changes nothing. A `startup` key is refused by the + /// configuration itself, for every principal (`CANNOT_MODIFY_STATIC_CONFIG`). + pub(crate) async fn authorize_set_key(&self, key: &str) -> Result<(), KrishivError> { + if krishiv_common::config::lookup(key) + .is_some_and(|e| e.settable_by == krishiv_common::config::SettableBy::Admin) + { + let r = authorize( + &*self.state.policy, + &self.state.principal, + &Securable::System("cluster".to_owned()), + Privilege::Manage, + ) + .await; + return crate::access::audit(self, StatementClass::Session, r); + } if !krishiv_io::is_server_scoped_option(key) { return Ok(()); } diff --git a/crates/krishiv-session/tests/roles_matrix.rs b/crates/krishiv-session/tests/roles_matrix.rs index d2a18d0ae..cece57e6e 100644 --- a/crates/krishiv-session/tests/roles_matrix.rs +++ b/crates/krishiv-session/tests/roles_matrix.rs @@ -60,6 +60,10 @@ const MATRIX: &[(&str, Role)] = &[ ("SELECT id FROM range(3)", Role::Viewer), // Session class. ("SET spark.sql.shuffle.partitions = 4", Role::Viewer), + // A key settable by `admin` only (ADR 0046 §3): `Manage` on the cluster, for `SET` and + // `RESET key` alike. + ("SET krishiv.exec.targetPartitionBytes = 64m", Role::Admin), + ("RESET krishiv.exec.targetPartitionBytes", Role::Admin), ("USE default", Role::Viewer), // Metadata (Browse). ("SHOW TABLES", Role::Viewer), From 89c7c3e7adbd6c3638e902a2c90c7aba282bdaf7 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Sat, 14 Nov 2026 05:49:23 +0000 Subject: [PATCH 37/47] =?UTF-8?q?connect-server:=20a=20session=20or=20oper?= =?UTF-8?q?ation=20id=20another=20principal=20holds=20is=20answered=20as?= =?UTF-8?q?=20unknown=20and=20writes=20an=20audit=20record=20(ADR=200046?= =?UTF-8?q?=20=C2=A73b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReattachExecute, the session lookups, a create of a foreign id and a release of one write one krishiv::audit record naming the caller and the kind of id, never the owner; unknown ids write none. Not counted in krishiv_auth_denied_total, which counts 42501 refusals only (§8). Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-connect-server/CLAUDE.md | 3 +- crates/krishiv-connect-server/src/manager.rs | 93 ++++++++++--- crates/krishiv-connect-server/src/service.rs | 10 +- .../tests/foreign_handles_audit.rs | 123 ++++++++++++++++++ 4 files changed, 209 insertions(+), 20 deletions(-) create mode 100644 crates/krishiv-connect-server/tests/foreign_handles_audit.rs diff --git a/crates/krishiv-connect-server/CLAUDE.md b/crates/krishiv-connect-server/CLAUDE.md index 473cd4fed..93834016a 100644 --- a/crates/krishiv-connect-server/CLAUDE.md +++ b/crates/krishiv-connect-server/CLAUDE.md @@ -57,7 +57,8 @@ Paths are relative to this crate. - `src/manager.rs` `SessionManager` (Spark's `SparkConnectSessionManager`; its factory gets the `Authenticated` caller; a new session records transport `connect`, the client's `user_id` (unverified, ADR 0046 §5) and its server-side id for `system.sessions`): get-or-create / lookup / release, tombstones - (LRU, no expiry), `SESSION_CHANGED` on a changed server-side id, the idle sweep (skips a session running a query or + (LRU, no expiry), `SESSION_CHANGED` on a changed server-side id, a `krishiv::audit` record (not counted) for a + session or operation id another owner holds (ADR 0046 §3b; `audit_foreign_operation`, `tests/foreign_handles_audit.rs`), the idle sweep (skips a session running a query or holding a live operation; `busy()` is what shutdown waits for), `krishiv.connect.maxSessions` / `maxSessionsPerPrincipal` - `src/server.rs` `ConnectServer::serve`: HTTP/2 limits, connection cap, sweeper task, shutdown order; `with_tls` (P3-T002 §3.5): the TLS handshake (ALPN `h2`) on the connection's own task within `handshakeTimeout`, `PeerInfo` (address and verified client leaf) as the connection's `ConnectInfo`, which `AuthLayer` passes to the authenticator; `tests/tls.rs` diff --git a/crates/krishiv-connect-server/src/manager.rs b/crates/krishiv-connect-server/src/manager.rs index fe7c8e1bc..4141da824 100644 --- a/crates/krishiv-connect-server/src/manager.rs +++ b/crates/krishiv-connect-server/src/manager.rs @@ -11,7 +11,13 @@ //! The key is the caller's [`SessionOwner`] (method, principal kind, tenant //! and name, without groups). A session id another owner holds is refused as //! unknown (`INVALID_HANDLE.SESSION_NOT_FOUND`), the answer a lookup of an -//! unknown id gets. +//! unknown id gets, and writes a `krishiv::audit` denial record (ADR 0046 +//! §3b: no existence oracle, but the denial is recorded); so does a release +//! of such an id (a no-op) and, through +//! [`SessionManager::audit_foreign_operation`], an operation id another +//! owner's session holds. These records are not counted in +//! `krishiv_auth_denied_total`, which counts `KRISHIV_INSUFFICIENT_PERMISSIONS` +//! refusals only (§8). //! //! A session's policy is fixed when it is built, from the caller's role. A //! caller whose role differs from the role its live session was built with @@ -39,7 +45,7 @@ use krishiv_common::error::{ InvalidHandleSessionChanged, InvalidHandleSessionClosed, InvalidHandleSessionNotFound, KrishivError, KrishivSessionLimit, }; -use krishiv_session::auth::Authenticated; +use krishiv_session::auth::{Authenticated, Surface}; use krishiv_session::{QueryId, Session}; use crate::auth::SessionOwner; @@ -391,7 +397,7 @@ impl SessionManager { Err(e) => { // Built, then refused by a check that changed meanwhile. entry.session.close(); - Err(e) + Err(audit_foreign(caller, "session", e)) } Ok(Some(existing)) => { entry.session.close(); @@ -468,7 +474,14 @@ impl SessionManager { let key = Self::key(caller, session_id); let entry = { let mut inner = self.lock(); - let entry = Self::remove_locked(&mut inner, &key)?; + let Some(entry) = Self::remove_locked(&mut inner, &key) else { + let foreign = Self::foreign_locked(&inner, &key); + drop(inner); + if foreign { + write_foreign_record(caller, "session"); + } + return None; + }; if !allow_reconnect { inner.closed.insert(key, self.limits.tombstones); } @@ -582,11 +595,7 @@ impl SessionManager { match self.live_locked(&mut inner, key, caller, &mut retired) { Some(e) => Ok(Some(e)), // Held by another owner: refused as unknown. - None if inner - .owners - .get(&key.session_id) - .is_some_and(|o| *o != key.owner) => - { + None if Self::foreign_locked(&inner, key) => { Err(KrishivError::from(InvalidHandleSessionNotFound { handle: key.session_id.clone(), })) @@ -595,13 +604,42 @@ impl SessionManager { } }; Self::close_retired(retired); - let Some(entry) = found? else { + let Some(entry) = found.map_err(|e| audit_foreign(caller, "session", e))? else { return Ok(None); }; Self::check_observed(key, &entry, observed)?; Ok(Some(entry)) } + /// Whether another owner holds `key`'s session id. + fn foreign_locked(inner: &Inner, key: &Key) -> bool { + inner + .owners + .get(&key.session_id) + .is_some_and(|o| *o != key.owner) + } + + /// Writes the denial record of ADR 0046 §3b when `operation_id`, unknown in + /// `caller`'s session, is a live operation of another owner's session (the + /// caller is answered `INVALID_HANDLE.OPERATION_NOT_FOUND` either way). An + /// error path only: it looks through every live session. + pub fn audit_foreign_operation(&self, caller: &Authenticated, operation_id: &str) { + let owner = SessionOwner::of(caller); + let others: Vec> = self + .lock() + .live + .iter() + .filter(|(k, _)| k.owner != owner) + .map(|(_, e)| Arc::clone(e)) + .collect(); + if others + .iter() + .any(|e| e.operations().get(operation_id).is_some()) + { + write_foreign_record(caller, "operation"); + } + } + fn check_observed( key: &Key, entry: &SessionEntry, @@ -633,7 +671,7 @@ impl SessionManager { } }; Self::close_retired(retired); - checked + checked.map_err(|e| audit_foreign(caller, "session", e)) } fn check_create_locked( @@ -649,11 +687,7 @@ impl SessionManager { } .into()); } - if inner - .owners - .get(&key.session_id) - .is_some_and(|o| *o != key.owner) - { + if Self::foreign_locked(inner, key) { return Err(InvalidHandleSessionNotFound { handle: key.session_id.clone(), } @@ -691,5 +725,32 @@ impl SessionManager { } } +/// `error` unchanged, after the ADR 0046 §3b denial record when it is the +/// `INVALID_HANDLE.SESSION_NOT_FOUND` of an id another owner holds (the only +/// `SESSION_NOT_FOUND` [`SessionManager::lookup`] and the create checks give). +fn audit_foreign( + caller: &Authenticated, + handle: &'static str, + error: KrishivError, +) -> KrishivError { + if error.error_class().name() == "INVALID_HANDLE.SESSION_NOT_FOUND" { + write_foreign_record(caller, handle); + } + error +} + +/// The `krishiv::audit` record of a refused id another principal holds: who +/// asked and what kind of id, never the id's owner. +fn write_foreign_record(caller: &Authenticated, handle: &'static str) { + tracing::warn!( + target: "krishiv::audit", + principal = %caller.principal.name, + method = caller.method.as_str(), + surface = Surface::Connect.as_str(), + handle, + "refused a Connect id another principal holds, answered as unknown" + ); +} + #[cfg(test)] mod tests; diff --git a/crates/krishiv-connect-server/src/service.rs b/crates/krishiv-connect-server/src/service.rs index 51f9fff27..20751ced2 100644 --- a/crates/krishiv-connect-server/src/service.rs +++ b/crates/krishiv-connect-server/src/service.rs @@ -269,10 +269,14 @@ impl SparkConnectService for ConnectService { ) .map_err(|e| status_of(&e))?; let error: KrishivError = match entry.operations().get(&request.operation_id) { - None => InvalidHandleOperationNotFound { - handle: request.operation_id.clone(), + None => { + self.manager + .audit_foreign_operation(&caller, &request.operation_id); + InvalidHandleOperationNotFound { + handle: request.operation_id.clone(), + } + .into() } - .into(), Some(op) if !op.reattachable() => InvalidCursorNotReattachable {}.into(), Some(_) => InvalidCursorDisconnected {}.into(), }; diff --git a/crates/krishiv-connect-server/tests/foreign_handles_audit.rs b/crates/krishiv-connect-server/tests/foreign_handles_audit.rs new file mode 100644 index 000000000..307b9bb06 --- /dev/null +++ b/crates/krishiv-connect-server/tests/foreign_handles_audit.rs @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: Apache-2.0 +//! ADR 0046 §3b: a session or operation id another principal holds answers as +//! a missing one and still writes one `krishiv::audit` denial record; an +//! unknown id writes none. One test per binary: the subscriber is +//! process-wide. +#![cfg(test)] + +use std::fmt::Write as _; +use std::sync::{Arc, Mutex}; + +use krishiv_common::principal::{Principal, PrincipalKind}; +use krishiv_connect_server::manager::{SessionLimits, SessionManager}; +use krishiv_connect_server::operation::{Operation, plan_digest}; +use krishiv_session::auth::{Authenticated, Method, Role}; +use krishiv_session::{QueryId, Session}; +use tracing::field::{Field, Visit}; +use tracing::span::{Attributes, Id, Record}; +use tracing::{Event, Metadata, Subscriber}; + +static RECORDS: Mutex> = Mutex::new(Vec::new()); + +struct Capture; + +struct Fields(String); + +impl Visit for Fields { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + let _ = write!(self.0, "{}={value:?} ", field.name()); + } + + fn record_str(&mut self, field: &Field, value: &str) { + let _ = write!(self.0, "{}={value} ", field.name()); + } +} + +impl Subscriber for Capture { + fn enabled(&self, _: &Metadata<'_>) -> bool { + true + } + + fn new_span(&self, _: &Attributes<'_>) -> Id { + Id::from_u64(1) + } + + fn record(&self, _: &Id, _: &Record<'_>) {} + + fn record_follows_from(&self, _: &Id, _: &Id) {} + + fn event(&self, event: &Event<'_>) { + if event.metadata().target() == "krishiv::audit" { + let mut fields = Fields(String::new()); + event.record(&mut fields); + RECORDS.lock().unwrap().push(fields.0); + } + } + + fn enter(&self, _: &Id) {} + + fn exit(&self, _: &Id) {} +} + +const ID: &str = "8f0e4c55-5ad0-4d1e-9a3c-3a1b0b2c7d10"; +const OP: &str = "1d6a3c1e-77b2-4c55-8d0f-0a6c2e9b4f21"; +const UNKNOWN: &str = "5b1f7a2e-0c3d-4e8f-9a6b-2d4c6e8f0a1b"; + +fn user(name: &str) -> Authenticated { + Authenticated::new( + Principal::new(name, PrincipalKind::User), + Some(Role::Operator), + Method::Token, + ) +} + +#[test] +fn foreign_ids_are_answered_as_unknown_and_recorded() { + tracing::subscriber::set_global_default(Capture).unwrap(); + let take = || std::mem::take(&mut *RECORDS.lock().unwrap()); + let m = SessionManager::from_base(Session::new().unwrap(), SessionLimits::default()); + let (alice, bob) = (user("alice"), user("bob")); + let entry = m.get_or_create(&alice, ID, None, "u").unwrap(); + let op = Arc::new(Operation::new( + QueryId::parse(OP).unwrap(), + plan_digest(b"p"), + Arc::from(Vec::new()), + false, + )); + entry.operations().register(&op, 10).unwrap(); + m.get_or_create(&bob, UNKNOWN, None, "u").unwrap(); + assert!(take().is_empty(), "own sessions write nothing"); + + // Unknown ids: no record. + let unknown = "0a0b0c0d-0e0f-4a1b-8c2d-3e4f5a6b7c8d"; + m.get(&bob, unknown, None).unwrap_err(); + assert_eq!(m.release(&bob, unknown, false), None); + m.audit_foreign_operation(&bob, unknown); + assert!(take().is_empty(), "unknown ids write nothing"); + + // Alice's ids, asked for by bob: one record each, naming bob and the kind of id. + for e in [ + m.get(&bob, ID, None).unwrap_err(), + m.get_or_create(&bob, ID, None, "alice").unwrap_err(), + ] { + assert_eq!(e.error_class().name(), "INVALID_HANDLE.SESSION_NOT_FOUND"); + } + assert_eq!(m.release(&bob, ID, false), None); + m.audit_foreign_operation(&bob, OP); + let records = take(); + assert_eq!(records.len(), 4, "{records:?}"); + for (r, handle) in records + .iter() + .zip(["session", "session", "session", "operation"]) + { + assert!(r.contains("principal=bob "), "{r}"); + assert!(r.contains("surface=connect "), "{r}"); + assert!(r.contains(&format!("handle={handle} ")), "{r}"); + assert!(!r.contains("alice"), "the owner is never named: {r}"); + } + + // Alice's own operation, from her own session: nothing. + m.audit_foreign_operation(&alice, OP); + assert!(take().is_empty()); + m.get(&alice, ID, None).unwrap(); +} From e722ec5750fb4a893d33f6e41018ee4c81b1a4bc Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Sat, 14 Nov 2026 05:53:04 +0000 Subject: [PATCH 38/47] common: krishiv.connect.handshakeTimeout also bounds the TLS handshake; 0s leaves a stalled handshake holding its slot Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-common/src/config/registry/server.rs | 4 +++- docs/reference/config/index.md | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/krishiv-common/src/config/registry/server.rs b/crates/krishiv-common/src/config/registry/server.rs index 22890bbc9..7f108e6fa 100644 --- a/crates/krishiv-common/src/config/registry/server.rs +++ b/crates/krishiv-common/src/config/registry/server.rs @@ -171,7 +171,9 @@ pub static CONNECT_HANDSHAKE_TIMEOUT: ConfigEntry = startup( ValueType::Duration, ConfigValue::Duration(Duration::from_secs(10)), "A new Spark Connect connection that sends no first byte for this long is closed, so silent \ - sockets cannot hold `krishiv.connect.maxConnections` for longer. `0s` means no deadline.", + sockets cannot hold `krishiv.connect.maxConnections` for longer; with TLS the whole handshake \ + must also finish within it. `0s` means no deadline for either, so a client that stalls \ + mid-handshake holds its connection slot until it disconnects.", None, ); diff --git a/docs/reference/config/index.md b/docs/reference/config/index.md index 2a36f1ad6..78d5fb9a6 100644 --- a/docs/reference/config/index.md +++ b/docs/reference/config/index.md @@ -14,7 +14,7 @@ Keys under `krishiv.experimental.` are experimental and off by default. Spark al | `krishiv.checkpoint.dir` | string | (none) | session | user | 0.2.0 | `spark.checkpoint.dir` | The directory `DataFrame.checkpoint()` writes its snapshot under (Spark's `setCheckpointDir`). Unset: `checkpoint()` fails with `_LEGACY_ERROR_TEMP_3016`, `localCheckpoint()` needs no directory. Unlike Spark, the files are deleted when the checkpointed DataFrame and every frame derived from it are gone. | | `krishiv.connect.address` | string | `127.0.0.1` | cluster | startup | 0.3.0 | `spark.connect.grpc.binding.address` | The address the Spark Connect listener binds. Loopback by default (Spark binds every interface); a non-loopback address needs authentication configured, or the server refuses to start. | | `krishiv.connect.execute.aheadBytes` | size | `64MB` | cluster | startup | 0.3.0 | | Most bytes of encoded `ExecutePlan` responses one operation holds before its client reads them; past it the query pauses until the client catches up (the gRPC flow-control window is the backpressure), so no result is held in full. The bytes count against the memory pool. | -| `krishiv.connect.handshakeTimeout` | duration | `10s` | cluster | startup | 0.3.0 | | A new Spark Connect connection that sends no first byte for this long is closed, so silent sockets cannot hold `krishiv.connect.maxConnections` for longer. `0s` means no deadline. | +| `krishiv.connect.handshakeTimeout` | duration | `10s` | cluster | startup | 0.3.0 | | A new Spark Connect connection that sends no first byte for this long is closed, so silent sockets cannot hold `krishiv.connect.maxConnections` for longer; with TLS the whole handshake must also finish within it. `0s` means no deadline for either, so a client that stalls mid-handshake holds its connection slot until it disconnects. | | `krishiv.connect.http2.maxConcurrentStreams` | positive int | `100` | cluster | startup | 0.3.0 | | Most concurrent RPCs (HTTP/2 streams) on one Spark Connect connection; more are refused. | | `krishiv.connect.http2.maxHeaderListBytes` | size | `16KB` | cluster | startup | 0.3.0 | | The largest header list of a Spark Connect request. | | `krishiv.connect.http2.maxPendingAcceptResets` | positive int | `20` | cluster | startup | 0.3.0 | | Streams a client may open and reset before the server accepts them, before the connection is closed (the HTTP/2 rapid-reset bound). | From c9ebefb17c030ad59f7dc41bf30aeadbd612558c Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Sat, 14 Nov 2026 06:16:36 +0000 Subject: [PATCH 39/47] connect-server, session: review fixes: ReleaseExecute and Interrupt record a foreign operation id, the record names the caller's role; SET of an admin key's Spark alias needs Manage too Review ops/reviews/2026-11-14-opus-A-t002-foreign-id-audit.md M1, N1, N3, N5. Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-connect-server/src/manager.rs | 10 ++++++--- crates/krishiv-connect-server/src/service.rs | 22 +++++++++++++++---- .../tests/foreign_handles_audit.rs | 1 + crates/krishiv-session/src/session/ops.rs | 3 +++ 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/crates/krishiv-connect-server/src/manager.rs b/crates/krishiv-connect-server/src/manager.rs index 4141da824..b08675dd6 100644 --- a/crates/krishiv-connect-server/src/manager.rs +++ b/crates/krishiv-connect-server/src/manager.rs @@ -45,7 +45,7 @@ use krishiv_common::error::{ InvalidHandleSessionChanged, InvalidHandleSessionClosed, InvalidHandleSessionNotFound, KrishivError, KrishivSessionLimit, }; -use krishiv_session::auth::{Authenticated, Surface}; +use krishiv_session::auth::{Authenticated, Role, Surface}; use krishiv_session::{QueryId, Session}; use crate::auth::SessionOwner; @@ -621,8 +621,11 @@ impl SessionManager { /// Writes the denial record of ADR 0046 §3b when `operation_id`, unknown in /// `caller`'s session, is a live operation of another owner's session (the - /// caller is answered `INVALID_HANDLE.OPERATION_NOT_FOUND` either way). An - /// error path only: it looks through every live session. + /// caller is answered as for an unknown id either way: `ReattachExecute`, + /// `ReleaseExecute`, `Interrupt` by id). It looks through every live + /// session (at most `krishiv.connect.maxSessions`), and runs on every + /// unknown operation id, which includes a normal client's reattach or + /// release after its operation finished. pub fn audit_foreign_operation(&self, caller: &Authenticated, operation_id: &str) { let owner = SessionOwner::of(caller); let others: Vec> = self @@ -746,6 +749,7 @@ fn write_foreign_record(caller: &Authenticated, handle: &'static str) { target: "krishiv::audit", principal = %caller.principal.name, method = caller.method.as_str(), + role = caller.role.map_or("none", Role::as_str), surface = Surface::Connect.as_str(), handle, "refused a Connect id another principal holds, answered as unknown" diff --git a/crates/krishiv-connect-server/src/service.rs b/crates/krishiv-connect-server/src/service.rs index 20751ced2..6213ed617 100644 --- a/crates/krishiv-connect-server/src/service.rs +++ b/crates/krishiv-connect-server/src/service.rs @@ -242,9 +242,16 @@ impl SparkConnectService for ConnectService { request.client_observed_server_side_session_id.as_deref(), request.user_context.as_ref(), )?; - interrupt::interrupt(&entry, &request) - .map(Response::new) - .map_err(|e| status_of(&e)) + let response = interrupt::interrupt(&entry, &request).map_err(|e| status_of(&e))?; + // An id it did not interrupt that is no operation of this session may be another + // owner's: answered the same (an empty list), recorded (ADR 0046 §3b). + if let Some(pb::interrupt_request::Interrupt::OperationId(id)) = &request.interrupt + && response.interrupted_ids.is_empty() + && entry.operations().get(id).is_none() + { + self.manager.audit_foreign_operation(&caller, id); + } + Ok(Response::new(response)) } /// Spark's `SparkConnectReattachExecuteHandler`, minimal (design @@ -304,7 +311,14 @@ impl SparkConnectService for ConnectService { server_side_session_id: entry.server_side_id().to_owned(), operation_id: None, }; - if let Some(op) = entry.operations().get(&request.operation_id) { + let found = entry.operations().get(&request.operation_id); + if found.is_none() { + // Answered as unknown (success, no operation id), recorded if another owner's + // (ADR 0046 §3b). + self.manager + .audit_foreign_operation(&caller, &request.operation_id); + } + if let Some(op) = found { if !op.reattachable() { return Err(status_of(&InvalidCursorNotReattachable {}.into())); } diff --git a/crates/krishiv-connect-server/tests/foreign_handles_audit.rs b/crates/krishiv-connect-server/tests/foreign_handles_audit.rs index 307b9bb06..ff09bc701 100644 --- a/crates/krishiv-connect-server/tests/foreign_handles_audit.rs +++ b/crates/krishiv-connect-server/tests/foreign_handles_audit.rs @@ -112,6 +112,7 @@ fn foreign_ids_are_answered_as_unknown_and_recorded() { { assert!(r.contains("principal=bob "), "{r}"); assert!(r.contains("surface=connect "), "{r}"); + assert!(r.contains("role=operator "), "{r}"); assert!(r.contains(&format!("handle={handle} ")), "{r}"); assert!(!r.contains("alice"), "the owner is never named: {r}"); } diff --git a/crates/krishiv-session/src/session/ops.rs b/crates/krishiv-session/src/session/ops.rs index 76803eb23..a2ddb8814 100644 --- a/crates/krishiv-session/src/session/ops.rs +++ b/crates/krishiv-session/src/session/ops.rs @@ -112,6 +112,9 @@ impl Session { /// configuration itself, for every principal (`CANNOT_MODIFY_STATIC_CONFIG`). pub(crate) async fn authorize_set_key(&self, key: &str) -> Result<(), KrishivError> { if krishiv_common::config::lookup(key) + .or_else(|| { + krishiv_common::config::lookup(krishiv_common::config::resolve_alternative(key)) + }) .is_some_and(|e| e.settable_by == krishiv_common::config::SettableBy::Admin) { let r = authorize( From 44048cb8fec4ff44eab77d13cf35e35a643cd17c Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Sat, 14 Nov 2026 08:34:42 +0000 Subject: [PATCH 40/47] connect-server, session: tests for the foreign-id audit paths (review N2, N6) N2: the race after a session is built (another owner creates the id meanwhile) is refused as unknown and writes exactly one record; a ReattachExecute of another principal's operation, end to end over the authenticated listener, is OPERATION_NOT_FOUND like an unknown id and writes one record naming the caller only. N6: the session's denial record carries the client's reported user_id as client_user_id_unverified, and omits it when none was reported. Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-connect-server/tests/auth.rs | 163 ++++++++++++++++++ .../tests/foreign_handles_audit.rs | 41 ++++- crates/krishiv-session/tests/access_audit.rs | 22 +++ 3 files changed, 225 insertions(+), 1 deletion(-) diff --git a/crates/krishiv-connect-server/tests/auth.rs b/crates/krishiv-connect-server/tests/auth.rs index d3198aa85..009559e94 100644 --- a/crates/krishiv-connect-server/tests/auth.rs +++ b/crates/krishiv-connect-server/tests/auth.rs @@ -566,3 +566,166 @@ async fn the_clients_user_id_is_recorded_not_trusted() { [("carol".to_owned(), "dana".to_owned(), "connect".to_owned())] ); } + +/// The `krishiv::audit` events of this thread, as `field=value` lines. +#[derive(Clone, Default)] +struct AuditCapture(Arc>>); + +impl AuditCapture { + fn take(&self) -> Vec { + std::mem::take( + &mut *self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ) + } +} + +struct AuditFields(String); + +impl tracing::field::Visit for AuditFields { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + use std::fmt::Write as _; + let _ = write!(self.0, "{}={value:?} ", field.name()); + } + + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + use std::fmt::Write as _; + let _ = write!(self.0, "{}={value} ", field.name()); + } +} + +impl tracing::Subscriber for AuditCapture { + fn enabled(&self, _: &tracing::Metadata<'_>) -> bool { + true + } + + fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(1) + } + + fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {} + + fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {} + + fn event(&self, event: &tracing::Event<'_>) { + if event.metadata().target() == "krishiv::audit" { + let mut fields = AuditFields(String::new()); + event.record(&mut fields); + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(fields.0); + } + } + + fn enter(&self, _: &tracing::span::Id) {} + + fn exit(&self, _: &tracing::span::Id) {} +} + +/// Review N2 (ADR 0046 §3b): `ReattachExecute` of another principal's operation id, end to +/// end. It is answered as an unknown id and writes one audit record naming the caller only. +/// A current-thread runtime, so the handler runs on this thread and the thread's subscriber +/// sees its record. +#[tokio::test(flavor = "current_thread")] +async fn reattaching_another_principals_operation_is_unknown_and_recorded() { + use krishiv_connect::proto::v4_3::execute_plan_request::request_option::RequestOption; + const OP: &str = "1d6a3c1e-77b2-4c55-8d0f-0a6c2e9b4f21"; + const UNKNOWN: &str = "5b1f7a2e-0c3d-4e8f-9a6b-2d4c6e8f0a1b"; + const OTHER_SESSION: &str = "2c4e6a8b-0d1f-4a3c-8e5b-7d9f1b3d5f70"; + let capture = AuditCapture::default(); + let _guard = tracing::subscriber::set_default(capture.clone()); + let run = start().await; + + // carol's reattachable operation, read to its end: it stays registered until released. + let request = pb::ExecutePlanRequest { + session_id: SESSION.to_owned(), + operation_id: Some(OP.to_owned()), + plan: Some(range(3)), + request_options: vec![pb::execute_plan_request::RequestOption { + request_option: Some(RequestOption::ReattachOptions(pb::ReattachOptions { + reattachable: true, + })), + }], + ..Default::default() + }; + let mut grpc = tonic::client::Grpc::new(run.channel.clone()); + grpc.ready().await.expect("ready"); + let mut stream = grpc + .server_streaming( + with_token(request, Some(VIEWER)), + path("ExecutePlan"), + tonic_prost::ProstCodec::::default(), + ) + .await + .expect("execute") + .into_inner(); + while stream.message().await.expect("response").is_some() {} + assert!( + capture.take().is_empty(), + "carol's own operation writes nothing" + ); + + let reattach = |operation_id: &str| pb::ReattachExecuteRequest { + session_id: OTHER_SESSION.to_owned(), + operation_id: operation_id.to_owned(), + ..Default::default() + }; + let reattach_as_dana = async |operation_id: &str| { + let mut grpc = tonic::client::Grpc::new(run.channel.clone()); + grpc.ready().await.expect("ready"); + grpc.server_streaming( + with_token(reattach(operation_id), Some(ADMIN)), + path("ReattachExecute"), + tonic_prost::ProstCodec::::default( + ), + ) + .await + .expect_err("not dana's operation") + }; + + // Dana (an admin) creates her own session first, so the reattach finds it. + execute_in(&run.channel, OTHER_SESSION, ADMIN).await; + capture.take(); + + let unknown = reattach_as_dana(UNKNOWN).await; + assert_eq!(error_class(&unknown), "INVALID_HANDLE.OPERATION_NOT_FOUND"); + assert!(capture.take().is_empty(), "an unknown id writes nothing"); + + let foreign = reattach_as_dana(OP).await; + assert_eq!( + error_class(&foreign), + "INVALID_HANDLE.OPERATION_NOT_FOUND", + "answered as the unknown id is" + ); + let records = capture.take(); + assert_eq!(records.len(), 1, "{records:?}"); + let r = &records[0]; + assert!(r.contains("principal=dana "), "{r}"); + assert!(r.contains("handle=operation "), "{r}"); + assert!(r.contains("surface=connect "), "{r}"); + assert!(!r.contains("carol"), "the owner is never named: {r}"); +} + +/// Runs a small plan in `session_id` as the holder of `token`, to create that session. +async fn execute_in(channel: &Channel, session_id: &str, token: &str) { + let request = pb::ExecutePlanRequest { + session_id: session_id.to_owned(), + plan: Some(range(1)), + ..Default::default() + }; + let mut grpc = tonic::client::Grpc::new(channel.clone()); + grpc.ready().await.expect("ready"); + let mut s = grpc + .server_streaming( + with_token(request, Some(token)), + path("ExecutePlan"), + tonic_prost::ProstCodec::::default(), + ) + .await + .expect("execute") + .into_inner(); + while s.message().await.expect("response").is_some() {} +} diff --git a/crates/krishiv-connect-server/tests/foreign_handles_audit.rs b/crates/krishiv-connect-server/tests/foreign_handles_audit.rs index ff09bc701..f3e32e236 100644 --- a/crates/krishiv-connect-server/tests/foreign_handles_audit.rs +++ b/crates/krishiv-connect-server/tests/foreign_handles_audit.rs @@ -6,7 +6,7 @@ #![cfg(test)] use std::fmt::Write as _; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; use krishiv_common::principal::{Principal, PrincipalKind}; use krishiv_connect_server::manager::{SessionLimits, SessionManager}; @@ -121,4 +121,43 @@ fn foreign_ids_are_answered_as_unknown_and_recorded() { m.audit_foreign_operation(&alice, OP); assert!(take().is_empty()); m.get(&alice, ID, None).unwrap(); + + // The race after the build (review N2): bob's lookup and caps check find the id free, and + // alice creates it while bob's session is being built. Bob's create is refused as unknown, + // its session closed unused, and exactly one record is written. + let racing = racing_manager(&alice); + let e = racing.get_or_create(&bob, ID, None, "u").unwrap_err(); + assert_eq!(e.error_class().name(), "INVALID_HANDLE.SESSION_NOT_FOUND"); + let records = take(); + assert_eq!(records.len(), 1, "{records:?}"); + assert!(records[0].contains("principal=bob "), "{}", records[0]); + assert!(records[0].contains("handle=session "), "{}", records[0]); + assert!(!records[0].contains("alice"), "{}", records[0]); + assert_eq!( + racing.counts().live, + 1, + "alice's session stays, bob's is dropped" + ); + assert!(!racing.get(&alice, ID, None).unwrap().session().is_closed()); +} + +/// A manager whose factory, building a session for anyone but `winner`, first creates +/// `winner`'s session of the same id: the create that is being built loses the race. +fn racing_manager(winner: &Authenticated) -> Arc { + let base = Session::new().unwrap(); + let this: Arc>> = Arc::new(OnceLock::new()); + let (hook, winner) = (Arc::clone(&this), winner.clone()); + let m = Arc::new(SessionManager::new( + Box::new(move |caller: &Authenticated| { + if caller.principal.name != winner.principal.name + && let Some(m) = hook.get().and_then(Weak::upgrade) + { + m.get_or_create(&winner, ID, None, "u").unwrap(); + } + Ok(base.new_session()) + }), + SessionLimits::default(), + )); + this.set(Arc::downgrade(&m)).unwrap(); + m } diff --git a/crates/krishiv-session/tests/access_audit.rs b/crates/krishiv-session/tests/access_audit.rs index f039ecfc3..a086f83ff 100644 --- a/crates/krishiv-session/tests/access_audit.rs +++ b/crates/krishiv-session/tests/access_audit.rs @@ -124,5 +124,27 @@ fn each_denial_leaves_one_audit_record() { !r.contains("deny-all"), "the policy's reason stays out: {r}" ); + assert!( + !r.contains("client_user_id_unverified"), + "no client id reported, none recorded: {r}" + ); } + + // ADR 0046 §5: a client's own `user_id` is recorded beside the principal, marked + // unverified (review N6). + s.set_client_info( + krishiv_session::Transport::Connect, + None, + Some("dana"), + None, + ); + block_on(s.sql("SELECT * FROM t")).unwrap_err(); + let records = take(); + assert_eq!(records.len(), 1, "{records:?}"); + assert!(records[0].contains("principal=ana "), "{}", records[0]); + assert!( + records[0].contains("client_user_id_unverified=dana "), + "{}", + records[0] + ); } From 487bff26ebaf4a69ce7d464a83bd09e6052cf532 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Sat, 14 Nov 2026 22:21:38 +0000 Subject: [PATCH 41/47] changelog: 769 added Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- changelog.d/769.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/769.added.md diff --git a/changelog.d/769.added.md b/changelog.d/769.added.md new file mode 100644 index 000000000..e1f533300 --- /dev/null +++ b/changelog.d/769.added.md @@ -0,0 +1 @@ +`krishiv server` authenticates every Spark Connect RPC once authentication is configured (static tokens, client certificates or OIDC): each session belongs to its caller, Connect requests follow the viewer/operator/admin role matrix, both ports serve TLS, and refusals are counted on `/metrics` (`krishiv_auth_failed_total`, `krishiv_auth_denied_total`). From 9643852fad1931235d17d32dcbb44d2147bba53a Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Sat, 14 Nov 2026 22:36:37 +0000 Subject: [PATCH 42/47] session: RESET key authorization moves to Session::unset_key command.rs is at 2,000 lines on main; the RESET check now lives in session/ops.rs so the file stays under the file-size limit. Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-session/src/command.rs | 8 ++------ crates/krishiv-session/src/session/ops.rs | 6 ++++++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/krishiv-session/src/command.rs b/crates/krishiv-session/src/command.rs index bb38cc6e9..2301dc1d6 100644 --- a/crates/krishiv-session/src/command.rs +++ b/crates/krishiv-session/src/command.rs @@ -210,12 +210,8 @@ impl Session { /// Spark. pub(crate) async fn exec_reset(&self, r: &Reset) -> Result { match &r.key { - // Back to what the session was built with: every key it set passed `SET`'s check. - None => self.state.conf.reset(), - Some(key) => { - self.authorize_set_key(key).await?; - self.conf().unset(key)?; - } + None => self.state.conf.reset(), // every key it set passed `SET`'s check + Some(key) => self.unset_key(key).await?, } self.empty_frame().await } diff --git a/crates/krishiv-session/src/session/ops.rs b/crates/krishiv-session/src/session/ops.rs index a2ddb8814..1951f45c6 100644 --- a/crates/krishiv-session/src/session/ops.rs +++ b/crates/krishiv-session/src/session/ops.rs @@ -105,6 +105,12 @@ impl Session { crate::access::audit(self, class, r) } + /// `RESET key`: the key's `SET` check ([`Self::authorize_set_key`]), then the unset. + pub(crate) async fn unset_key(&self, key: &str) -> Result<(), KrishivError> { + self.authorize_set_key(key).await?; + self.conf().unset(key) + } + /// `SET key = ..` (or `RESET key`) of a key only an administrator may change is `Manage` /// on the cluster (ADR 0046 §3): a registry key settable by `admin` (its Spark alias too), /// or a server-scoped store key (P3-T067b). Any other key passes. Asked before the value From c31d121f7671d0fe9039fb0c20caac1f0bbd4bcb Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Sat, 14 Nov 2026 23:31:59 +0000 Subject: [PATCH 43/47] docs: regenerate the configuration reference (krishiv.connect.handshakeTimeout) Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- docs/reference/config/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/config/index.md b/docs/reference/config/index.md index 646fe656b..1c665ccd8 100644 --- a/docs/reference/config/index.md +++ b/docs/reference/config/index.md @@ -21,7 +21,7 @@ Keys under `krishiv.experimental.` are experimental and off by default. Spark al | `krishiv.connect.execute.retryBufferSize` | size | `10MB` | cluster | startup | 0.3.0 | `spark.connect.execute.reattachable.observerRetryBufferSize` | Bytes of already-sent `ExecutePlan` responses a reattachable operation keeps, newest first, so a client that lost its stream can reattach and resume; older ones are dropped. `0` keeps none. The bytes count against the memory pool. | | `krishiv.connect.execute.senderMaxStreamDuration` | duration (bare number: ms) | `2min` | cluster | startup | 0.3.0 | `spark.connect.execute.reattachable.senderMaxStreamDuration` | After this long a reattachable `ExecutePlan` or `ReattachExecute` stream ends without `ResultComplete` and the client reattaches to continue. `0` never ends it. | | `krishiv.connect.execute.senderMaxStreamSize` | size | `1GB` | cluster | startup | 0.3.0 | `spark.connect.execute.reattachable.senderMaxStreamSize` | After sending this many bytes (the response that crosses it is still sent) a reattachable stream ends without `ResultComplete` and the client reattaches to continue. `0` never ends it. | -| `krishiv.connect.handshakeTimeout` | duration | `10s` | cluster | startup | 0.3.0 | | A new Spark Connect connection that sends no first byte for this long is closed, so silent sockets cannot hold `krishiv.connect.maxConnections` for longer. `0s` means no deadline. | +| `krishiv.connect.handshakeTimeout` | duration | `10s` | cluster | startup | 0.3.0 | | A new Spark Connect connection that sends no first byte for this long is closed, so silent sockets cannot hold `krishiv.connect.maxConnections` for longer; with TLS the whole handshake must also finish within it. `0s` means no deadline for either, so a client that stalls mid-handshake holds its connection slot until it disconnects. | | `krishiv.connect.http2.maxConcurrentStreams` | positive int | `100` | cluster | startup | 0.3.0 | | Most concurrent RPCs (HTTP/2 streams) on one Spark Connect connection; more are refused. | | `krishiv.connect.http2.maxHeaderListBytes` | size | `16KB` | cluster | startup | 0.3.0 | | The largest header list of a Spark Connect request. | | `krishiv.connect.http2.maxPendingAcceptResets` | positive int | `20` | cluster | startup | 0.3.0 | | Streams a client may open and reset before the server accepts them, before the connection is closed (the HTTP/2 rapid-reset bound). | From 9fe4ebf16bc2ab8740c12793833818a0053f7174 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Sat, 14 Nov 2026 23:42:58 +0000 Subject: [PATCH 44/47] connect-server: Config set and unset ask the session's policy as SQL SET does A Config RPC refusal came from the role check alone: no audit record, no krishiv_auth_denied_total count, and server-scoped store keys (fs.*) were not checked at all (Codex review on #769). Set and unset now call Session::authorize_set_key (made public) before check_settable; a silent set still turns the refusal into a warning. Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-connect-server/CLAUDE.md | 2 +- crates/krishiv-connect-server/src/config.rs | 33 +++++-- crates/krishiv-connect-server/src/service.rs | 1 + crates/krishiv-connect-server/tests/auth.rs | 95 ++++++++++++++++++++ crates/krishiv-session/public-api.txt | 1 + crates/krishiv-session/src/session/ops.rs | 6 +- 6 files changed, 130 insertions(+), 8 deletions(-) diff --git a/crates/krishiv-connect-server/CLAUDE.md b/crates/krishiv-connect-server/CLAUDE.md index 3a9d9dcbe..0d122140b 100644 --- a/crates/krishiv-connect-server/CLAUDE.md +++ b/crates/krishiv-connect-server/CLAUDE.md @@ -25,7 +25,7 @@ Paths are relative to this crate. `parseDatatypeString` - `src/config.rs` `Config` (design §3.6) over `RuntimeConf`: the seven operations, `silent`, Spark's per-key warnings, redaction for both profiles (Spark's pattern plus `krishiv_common::secret`'s classifier and URL - credentials), `SettableBy` (`check_settable`; `role_of` is the caller's authenticated role, no role a viewer, the local user of an unauthenticated server the admin) + credentials), `SettableBy` (a `set`/`unset` first asks the session's policy as SQL `SET` does, `Session::authorize_set_key`: admin keys and server-scoped store keys need `Manage`, refusals audited and counted; then `check_settable`; `role_of` is the caller's authenticated role, no role a viewer, the local user of an unauthenticated server the admin) - `src/interrupt.rs` `Interrupt` (design §3.7): `ALL` and `OPERATION_ID` through the session's operations and `Session::cancel` per reported id; an interrupted operation stays registered (Spark keeps the `ExecuteHolder`), so a reattach gets `OPERATION_CANCELED`; `TAG` is `UNSUPPORTED_FEATURE.INTERRUPT_TYPE` until T064 diff --git a/crates/krishiv-connect-server/src/config.rs b/crates/krishiv-connect-server/src/config.rs index c0eac8a81..ba610d0fa 100644 --- a/crates/krishiv-connect-server/src/config.rs +++ b/crates/krishiv-connect-server/src/config.rs @@ -258,7 +258,7 @@ fn message(e: &KrishivError) -> String { /// module docs); `CONNECT_INVALID_PLAN.INVALID_ONE_OF_FIELD_NOT_SET` for a /// request without an operation (Spark throws a classless /// `UnsupportedOperationException`: a recorded difference). -pub(crate) fn config( +pub(crate) async fn config( entry: &SessionEntry, request: &pb::ConfigRequest, principal: &Principal, @@ -283,7 +283,7 @@ pub(crate) fn config( Some(OpType::Set(set)) => { let silent = set.silent.unwrap_or(false); for kv in &set.pairs { - match set_one(&conf, principal, role, &kv.key, kv.value.as_deref()) { + match set_one(entry, principal, role, &kv.key, kv.value.as_deref()).await { Ok(()) => out.warnings.extend(warning(&kv.key)), Err(e) if silent => { let shown = kv @@ -365,7 +365,7 @@ pub(crate) fn config( } Some(OpType::Unset(u)) => { for key in &u.keys { - check_settable(role, principal, key)?; + authorize(entry, role, principal, key).await?; conf.unset(key)?; out.warnings.extend(warning(key)); } @@ -381,14 +381,35 @@ pub(crate) fn config( Ok(out) } -fn set_one( - conf: &RuntimeConf, +/// The checks of a `set` or `unset` of `key`: a `Startup` key is refused for every role, then +/// the session asks its policy as SQL `SET` does (`Session::authorize_set_key`: an `Admin` key +/// or a server-scoped store key needs `Manage` on the cluster; a denial is audited and +/// counted), then the role's `SettableBy`. +async fn authorize( + entry: &SessionEntry, + role: ConfigRole, + principal: &Principal, + key: &str, +) -> Result<(), KrishivError> { + if matches!( + lookup(key).map(|e| e.settable_by), + Some(SettableBy::Startup) + ) { + return check_settable(role, principal, key); + } + entry.session().authorize_set_key(key).await?; + check_settable(role, principal, key) +} + +async fn set_one( + entry: &SessionEntry, principal: &Principal, role: ConfigRole, key: &str, value: Option<&str>, ) -> Result<(), KrishivError> { - check_settable(role, principal, key)?; + authorize(entry, role, principal, key).await?; + let conf = entry.session().conf(); // Spark's `require(value != null)` is a classless // `IllegalArgumentException`: a recorded difference. let Some(value) = value else { diff --git a/crates/krishiv-connect-server/src/service.rs b/crates/krishiv-connect-server/src/service.rs index 96f424ef4..92751deb9 100644 --- a/crates/krishiv-connect-server/src/service.rs +++ b/crates/krishiv-connect-server/src/service.rs @@ -238,6 +238,7 @@ impl SparkConnectService for ConnectService { &caller.principal, config::role_of(&caller), ) + .await .map(Response::new) .map_err(|e| status_of(&e)) } diff --git a/crates/krishiv-connect-server/tests/auth.rs b/crates/krishiv-connect-server/tests/auth.rs index 009559e94..47fa38f29 100644 --- a/crates/krishiv-connect-server/tests/auth.rs +++ b/crates/krishiv-connect-server/tests/auth.rs @@ -729,3 +729,98 @@ async fn execute_in(channel: &Channel, session_id: &str, token: &str) { .into_inner(); while s.message().await.expect("response").is_some() {} } + +/// A `Config` request of `ops` in [`SESSION`] as `token`. +async fn config_as( + run: &Running, + token: &str, + op: krishiv_connect::proto::v4_3::config_request::operation::OpType, +) -> Result { + unary( + &run.channel, + "Config", + pb::ConfigRequest { + session_id: SESSION.to_owned(), + operation: Some(pb::config_request::Operation { op_type: Some(op) }), + ..Default::default() + }, + Some(token), + ) + .await +} + +/// `Config` set and unset of an admin-only key or a server-scoped store key ask the session's +/// policy as SQL `SET` does: a refusal is `PERMISSION_DENIED`, audited and counted under +/// `connect` and `MANAGE` (ADR 0046 §8), a silent set turns it into a warning (still counted) +/// and changes nothing, and an admin may. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_refusals_are_counted_like_sql_set() { + use krishiv_connect::proto::v4_3::config_request::{self as creq, operation::OpType as Op}; + let kv = |key: &str, value: &str| pb::KeyValue { + key: key.to_owned(), + value: Some(value.to_owned()), + }; + let run = start().await; + let set = |silent| { + Op::Set(creq::Set { + pairs: vec![kv("krishiv.resultCache.ttl", "1m")], + silent: Some(silent), + }) + }; + let s = config_as(&run, OPERATOR, set(false)) + .await + .expect_err("admin key"); + assert_eq!(s.code(), Code::PermissionDenied); + assert_eq!(error_class(&s), "KRISHIV_INSUFFICIENT_PERMISSIONS"); + assert_eq!(denied(&run), [(Surface::Connect, Privilege::Manage, 1)]); + + let quiet = config_as(&run, OPERATOR, set(true)).await.expect("silent"); + assert!( + quiet + .warnings + .iter() + .any(|w| w.contains("krishiv.resultCache.ttl")), + "{:?}", + quiet.warnings + ); + let s = config_as( + &run, + OPERATOR, + Op::Unset(creq::Unset { + keys: vec!["krishiv.resultCache.ttl".to_owned()], + }), + ) + .await + .expect_err("admin key"); + assert_eq!(s.code(), Code::PermissionDenied); + let s = config_as( + &run, + OPERATOR, + Op::Set(creq::Set { + pairs: vec![kv("fs.s3a.endpoint", "http://169.254.169.254")], + silent: None, + }), + ) + .await + .expect_err("server-scoped store key"); + assert_eq!(s.code(), Code::PermissionDenied); + assert_eq!(denied(&run), [(Surface::Connect, Privilege::Manage, 4)]); + let got = config_as( + &run, + OPERATOR, + Op::GetOption(creq::GetOption { + keys: vec!["krishiv.resultCache.ttl".to_owned()], + }), + ) + .await + .expect("get"); + assert_ne!( + got.pairs[0].value.as_deref(), + Some("1m"), + "a refused set changes nothing" + ); + release_as(&run, OPERATOR).await; + + config_as(&run, ADMIN, set(false)).await.expect("admin may"); + assert_eq!(denied(&run), [(Surface::Connect, Privilege::Manage, 4)]); +} diff --git a/crates/krishiv-session/public-api.txt b/crates/krishiv-session/public-api.txt index f5678dcfe..dc3537350 100644 --- a/crates/krishiv-session/public-api.txt +++ b/crates/krishiv-session/public-api.txt @@ -1106,6 +1106,7 @@ pub fn krishiv_session::RuntimeConf::fmt(&self, &mut core::fmt::Formatter<'_>) - pub struct krishiv_session::Session impl krishiv_session::Session pub async fn krishiv_session::Session::authorize_privilege(&self, &krishiv_plan::analyzer::catalog::Securable, krishiv_plan::analyzer::catalog::Privilege, krishiv_sql::classify::StatementClass) -> core::result::Result<(), krishiv_common::error::KrishivError> +pub async fn krishiv_session::Session::authorize_set_key(&self, &str) -> core::result::Result<(), krishiv_common::error::KrishivError> pub fn krishiv_session::Session::count_denials(&self, alloc::sync::Arc, krishiv_session::auth::Surface) -> bool impl krishiv_session::Session pub fn krishiv_session::Session::baggage(&self) -> alloc::vec::Vec<(alloc::string::String, alloc::string::String)> diff --git a/crates/krishiv-session/src/session/ops.rs b/crates/krishiv-session/src/session/ops.rs index 1951f45c6..939d985fb 100644 --- a/crates/krishiv-session/src/session/ops.rs +++ b/crates/krishiv-session/src/session/ops.rs @@ -116,7 +116,11 @@ impl Session { /// or a server-scoped store key (P3-T067b). Any other key passes. Asked before the value /// is stored, so a refused `SET` changes nothing. A `startup` key is refused by the /// configuration itself, for every principal (`CANNOT_MODIFY_STATIC_CONFIG`). - pub(crate) async fn authorize_set_key(&self, key: &str) -> Result<(), KrishivError> { + /// + /// # Errors + /// `KRISHIV_INSUFFICIENT_PERMISSIONS` (42501) when the principal lacks `Manage`; the + /// denial is audited and counted like every other. + pub async fn authorize_set_key(&self, key: &str) -> Result<(), KrishivError> { if krishiv_common::config::lookup(key) .or_else(|| { krishiv_common::config::lookup(krishiv_common::config::resolve_alternative(key)) From 8f28f65f2529f3a11d7bd6012ac4456b7ef73b78 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Sun, 15 Nov 2026 04:01:01 +0000 Subject: [PATCH 45/47] ci: the test job drops the workspace test executables before the feature-gated runs Three test runs of this branch died linking krishiv-server (ld Bus error, then "No space left on device" in the runner itself): the feature-gated steps rebuild test executables beside the workspace ones, which have already run. Libraries stay, so nothing is compiled again. Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9018dd72..ce4206f8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -305,6 +305,14 @@ jobs: KRISHIV_TEST_UDF_PYTHON: ${{ runner.temp }}/udf-python/bin/python KRISHIV_UDF_POOL_REQUIRED: "1" - run: cargo test --workspace --doc --locked + # The feature-gated runs below rebuild their crates' test executables beside the workspace's, which + # have already run: drop those first, or the krishiv-server link runs out of disk (ld Bus error, #769). + # Libraries (`.rlib`, `.rmeta`, `.so`) stay, so nothing is compiled again. + - name: Drop the workspace's test executables + run: | + df -h / + find target/debug/deps -maxdepth 1 -type f -perm -u+x ! -name '*.so' ! -name '*.d' -delete + df -h / - run: cargo nextest run -p krishiv-session --features telemetry-otlp --test otlp_collector --locked # Server TLS (`tls`) and OIDC (`oidc`), both off by default: the in-memory rustls handshakes, # and the JWT verifier against keys generated per run. From 55716b0e492f37a1f7956958aeb0cd7a3713e7af Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:24:17 +0000 Subject: [PATCH 46/47] connect-server: Config set and unset are decided by the session's policy alone (review M1, N1-N4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The listener's own role check (check_settable, role_of) could refuse with 42501 unaudited and uncounted when a policy and the role disagreed (ADR 0046 §8 and its rejected listener-local check). A Startup key stays refused for everyone (check_static, looked up as authorize_set_key does); every other key is Session::authorize_set_key, as SQL SET. Tests: an admin key named by its Spark alias is refused and counted; the admin's value reads back (1min), and the refused-set check now compares the normalized value, so it no longer passes trivially. Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-connect-server/CLAUDE.md | 2 +- crates/krishiv-connect-server/src/config.rs | 116 +++++------------- .../src/config/tests.rs | 42 ++----- crates/krishiv-connect-server/src/service.rs | 13 +- crates/krishiv-connect-server/tests/auth.rs | 37 +++++- crates/krishiv-session/src/session/ops.rs | 3 + 6 files changed, 79 insertions(+), 134 deletions(-) diff --git a/crates/krishiv-connect-server/CLAUDE.md b/crates/krishiv-connect-server/CLAUDE.md index 0d122140b..136c0a52a 100644 --- a/crates/krishiv-connect-server/CLAUDE.md +++ b/crates/krishiv-connect-server/CLAUDE.md @@ -25,7 +25,7 @@ Paths are relative to this crate. `parseDatatypeString` - `src/config.rs` `Config` (design §3.6) over `RuntimeConf`: the seven operations, `silent`, Spark's per-key warnings, redaction for both profiles (Spark's pattern plus `krishiv_common::secret`'s classifier and URL - credentials), `SettableBy` (a `set`/`unset` first asks the session's policy as SQL `SET` does, `Session::authorize_set_key`: admin keys and server-scoped store keys need `Manage`, refusals audited and counted; then `check_settable`; `role_of` is the caller's authenticated role, no role a viewer, the local user of an unauthenticated server the admin) + credentials), who may `set`/`unset` a key (a `Startup` key is `CANNOT_MODIFY_STATIC_CONFIG` for everyone, `check_static`; any other key asks the session's policy as SQL `SET` does, `Session::authorize_set_key`: admin keys and server-scoped store keys need `Manage`, refusals audited and counted; no listener-local role check, ADR 0046) - `src/interrupt.rs` `Interrupt` (design §3.7): `ALL` and `OPERATION_ID` through the session's operations and `Session::cancel` per reported id; an interrupted operation stays registered (Spark keeps the `ExecuteHolder`), so a reattach gets `OPERATION_CANCELED`; `TAG` is `UNSUPPORTED_FEATURE.INTERRUPT_TYPE` until T064 diff --git a/crates/krishiv-connect-server/src/config.rs b/crates/krishiv-connect-server/src/config.rs index ba610d0fa..a27a5fdda 100644 --- a/crates/krishiv-connect-server/src/config.rs +++ b/crates/krishiv-connect-server/src/config.rs @@ -5,10 +5,12 @@ //! - `set(pairs, silent)`: per pair, in order; with `silent` a failure is //! the warning `Failed to set to Some() due to ` //! (Spark prints the Scala `Option`) and the next pair is tried. Who may -//! set a key is the registry's `SettableBy` (ADR 0046 §8, threat model -//! T-Z3): a `Startup` key is `CANNOT_MODIFY_STATIC_CONFIG` for every role, -//! an `Admin` key `KRISHIV_INSUFFICIENT_PERMISSIONS` (42501) for a viewer or -//! operator (`check_settable`). +//! set or unset a key (ADR 0046 §3, §8, threat model T-Z3): a `Startup` +//! key is `CANNOT_MODIFY_STATIC_CONFIG` for everyone (`check_static`); +//! any other key is the session's policy, asked as SQL `SET` asks it +//! (`Session::authorize_set_key`: an `Admin` key or a server-scoped store +//! key needs `Manage` on the cluster, else `KRISHIV_INSUFFICIENT_PERMISSIONS` +//! (42501), audited and counted). The listener keeps no role check of its own. //! - `get(keys)`: an unset key without a default is `SQL_CONF_NOT_FOUND` //! (Spark's `SQLConf.getConfString`, `SQLConf.scala:9801-9808`). //! - `get_with_default`: a set value, else the caller's default (a @@ -33,74 +35,35 @@ use std::borrow::Cow; use krishiv_common::config::{SettableBy, lookup, resolve_alternative}; use krishiv_common::error::{ CannotModifyStaticConfig, ConnectInvalidPlanInvalidOneOfFieldNotSet, - InvalidConfValueRequirement, KrishivError, KrishivInsufficientPermissions, SqlConfNotFound, + InvalidConfValueRequirement, KrishivError, SqlConfNotFound, }; -use krishiv_common::principal::Principal; use krishiv_common::redact::redact; use krishiv_common::secret::is_sensitive_key; use krishiv_connect::proto::v4_3 as pb; use krishiv_connect::proto::v4_3::config_request::operation::OpType; use krishiv_session::RuntimeConf; -use krishiv_session::auth::{Authenticated, Method, Role}; use crate::manager::SessionEntry; -/// What a principal may do with configuration (ADR 0046 §8). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ConfigRole { - /// Reads and sets `User` keys only. - Viewer, - /// Reads and sets `User` keys only. - Operator, - /// Sets `Admin` keys too. - Admin, -} - -/// The configuration role of `caller` (P3-T002, ADR 0046): its authenticated role; a caller -/// with none is a viewer. With authentication off every request is the local user on a -/// loopback listener (design P3-T001 §3.6), which is the admin. -/// -/// SQL `SET` takes the same gate through the session's policy (`Manage` on the cluster for an -/// `admin` key). TODO(P3-T002 follow-up): have this RPC ask the session's policy too instead -/// of [`check_settable`] (ADR 0046 rejects listener-local checks), and hide `Startup` and -/// `Admin` keys from `get_all` for viewers and operators. -#[must_use] -pub(crate) fn role_of(caller: &Authenticated) -> ConfigRole { - match (caller.method, caller.role) { - (Method::None, _) | (_, Some(Role::Admin)) => ConfigRole::Admin, - (_, Some(Role::Operator)) => ConfigRole::Operator, - (_, Some(Role::Viewer) | None) => ConfigRole::Viewer, - } -} - -/// Whether `role` may set or unset `key` (the registry's `SettableBy`). +/// A `Startup` key cannot change in a running session, for anyone. The key is looked up as +/// `Session::authorize_set_key` looks it up (its Spark alias or legacy alternative too). /// /// # Errors -/// `CANNOT_MODIFY_STATIC_CONFIG` for a `Startup` key (every role); -/// `KRISHIV_INSUFFICIENT_PERMISSIONS` for an `Admin` key and a role other -/// than [`ConfigRole::Admin`]. -pub(crate) fn check_settable( - role: ConfigRole, - principal: &Principal, - key: &str, -) -> Result<(), KrishivError> { - match lookup(key).map(|e| e.settable_by) { - Some(SettableBy::Startup) => Err(CannotModifyStaticConfig { +/// `CANNOT_MODIFY_STATIC_CONFIG` for a `Startup` key. +pub(crate) fn check_static(key: &str) -> Result<(), KrishivError> { + let entry = lookup(key).or_else(|| lookup(resolve_alternative(key))); + if entry.is_some_and(|e| e.settable_by == SettableBy::Startup) { + return Err(CannotModifyStaticConfig { key: format!("\"{key}\""), } - .into()), - Some(SettableBy::Admin) if role != ConfigRole::Admin => { - Err(KrishivInsufficientPermissions { - principal: principal.name.clone(), - privilege: "SET".to_owned(), - securable: format!("configuration `{key}`"), - } - .into()) - } - _ => Ok(()), + .into()); } + Ok(()) } +// TODO(P3-T002 follow-up): hide `Startup` and `Admin` keys from `get_all` for callers the +// session's policy would not let set them. + /// The two SQL configs Spark Connect warns about /// (`SparkConnectConfigHandler.unsupportedConfigurations`, line 217-218). const UNSUPPORTED: [&str; 2] = [ @@ -261,8 +224,6 @@ fn message(e: &KrishivError) -> String { pub(crate) async fn config( entry: &SessionEntry, request: &pb::ConfigRequest, - principal: &Principal, - role: ConfigRole, ) -> Result { let conf = entry.session().conf(); let mut out = pb::ConfigResponse { @@ -283,7 +244,7 @@ pub(crate) async fn config( Some(OpType::Set(set)) => { let silent = set.silent.unwrap_or(false); for kv in &set.pairs { - match set_one(entry, principal, role, &kv.key, kv.value.as_deref()).await { + match set_one(entry, &kv.key, kv.value.as_deref()).await { Ok(()) => out.warnings.extend(warning(&kv.key)), Err(e) if silent => { let shown = kv @@ -365,7 +326,7 @@ pub(crate) async fn config( } Some(OpType::Unset(u)) => { for key in &u.keys { - authorize(entry, role, principal, key).await?; + authorize(entry, key).await?; conf.unset(key)?; out.warnings.extend(warning(key)); } @@ -381,34 +342,17 @@ pub(crate) async fn config( Ok(out) } -/// The checks of a `set` or `unset` of `key`: a `Startup` key is refused for every role, then -/// the session asks its policy as SQL `SET` does (`Session::authorize_set_key`: an `Admin` key -/// or a server-scoped store key needs `Manage` on the cluster; a denial is audited and -/// counted), then the role's `SettableBy`. -async fn authorize( - entry: &SessionEntry, - role: ConfigRole, - principal: &Principal, - key: &str, -) -> Result<(), KrishivError> { - if matches!( - lookup(key).map(|e| e.settable_by), - Some(SettableBy::Startup) - ) { - return check_settable(role, principal, key); - } - entry.session().authorize_set_key(key).await?; - check_settable(role, principal, key) +/// The checks of a `set` or `unset` of `key`: a `Startup` key is refused for everyone first +/// (no policy call, nothing counted: not an auth denial, ADR 0046 §3), then the session asks +/// its policy as SQL `SET` does (`Session::authorize_set_key`: an `Admin` key or a +/// server-scoped store key needs `Manage` on the cluster; a denial is audited and counted). +async fn authorize(entry: &SessionEntry, key: &str) -> Result<(), KrishivError> { + check_static(key)?; + entry.session().authorize_set_key(key).await } -async fn set_one( - entry: &SessionEntry, - principal: &Principal, - role: ConfigRole, - key: &str, - value: Option<&str>, -) -> Result<(), KrishivError> { - authorize(entry, role, principal, key).await?; +async fn set_one(entry: &SessionEntry, key: &str, value: Option<&str>) -> Result<(), KrishivError> { + authorize(entry, key).await?; let conf = entry.session().conf(); // Spark's `require(value != null)` is a classless // `IllegalArgumentException`: a recorded difference. diff --git a/crates/krishiv-connect-server/src/config/tests.rs b/crates/krishiv-connect-server/src/config/tests.rs index fe732878b..1d29eefcd 100644 --- a/crates/krishiv-connect-server/src/config/tests.rs +++ b/crates/krishiv-connect-server/src/config/tests.rs @@ -1,45 +1,21 @@ // SPDX-License-Identifier: Apache-2.0 //! Unit tests of `SettableBy`, redaction and the per-key warnings. -use krishiv_common::principal::{Principal, PrincipalKind}; - -use super::{ConfigRole, check_settable, is_redacted, role_of, warning}; +use super::{check_static, is_redacted, warning}; const STARTUP: &str = "krishiv.resultCache.maxBytes"; const ADMIN: &str = "krishiv.resultCache.ttl"; const USER: &str = "spark.sql.ansi.enabled"; +/// A `Startup` key is refused here for everyone; an `Admin` key is left to the session's +/// policy (`tests/auth.rs` `config_refusals_are_counted_like_sql_set`). #[test] fn config_settable_by() { - let p = Principal::new("ann", PrincipalKind::User); - for role in [ConfigRole::Viewer, ConfigRole::Operator, ConfigRole::Admin] { - let e = check_settable(role, &p, STARTUP).unwrap_err(); - assert_eq!(e.error_class().name(), "CANNOT_MODIFY_STATIC_CONFIG"); - assert_eq!(e.error_class().sql_state(), Some("46110")); - check_settable(role, &p, USER).unwrap(); - check_settable(role, &p, "my.app.flag").unwrap(); - } - for role in [ConfigRole::Viewer, ConfigRole::Operator] { - let e = check_settable(role, &p, ADMIN).unwrap_err(); - assert_eq!(e.error_class().name(), "KRISHIV_INSUFFICIENT_PERMISSIONS"); - assert_eq!(e.error_class().sql_state(), Some("42501")); - } - check_settable(ConfigRole::Admin, &p, ADMIN).unwrap(); - // Authentication off: the local user (loopback only) is the admin. Otherwise the caller's - // role, and no role is a viewer. - use krishiv_session::auth::{Authenticated, Method, Role}; - let local = Authenticated::new(Principal::local_user(), None, Method::None); - assert_eq!(role_of(&local), ConfigRole::Admin); - let as_role = |role| { - role_of(&Authenticated::new( - Principal::new("ann", krishiv_common::principal::PrincipalKind::User), - role, - Method::Token, - )) - }; - assert_eq!(as_role(Some(Role::Admin)), ConfigRole::Admin); - assert_eq!(as_role(Some(Role::Operator)), ConfigRole::Operator); - assert_eq!(as_role(Some(Role::Viewer)), ConfigRole::Viewer); - assert_eq!(as_role(None), ConfigRole::Viewer); + let e = check_static(STARTUP).unwrap_err(); + assert_eq!(e.error_class().name(), "CANNOT_MODIFY_STATIC_CONFIG"); + assert_eq!(e.error_class().sql_state(), Some("46110")); + check_static(ADMIN).unwrap(); + check_static(USER).unwrap(); + check_static("my.app.flag").unwrap(); } #[test] diff --git a/crates/krishiv-connect-server/src/service.rs b/crates/krishiv-connect-server/src/service.rs index 92751deb9..825df807f 100644 --- a/crates/krishiv-connect-server/src/service.rs +++ b/crates/krishiv-connect-server/src/service.rs @@ -232,15 +232,10 @@ impl SparkConnectService for ConnectService { request.client_observed_server_side_session_id.as_deref(), request.user_context.as_ref(), )?; - config::config( - &entry, - &request, - &caller.principal, - config::role_of(&caller), - ) - .await - .map(Response::new) - .map_err(|e| status_of(&e)) + config::config(&entry, &request) + .await + .map(Response::new) + .map_err(|e| status_of(&e)) } async fn add_artifacts( diff --git a/crates/krishiv-connect-server/tests/auth.rs b/crates/krishiv-connect-server/tests/auth.rs index 47fa38f29..a09c199e4 100644 --- a/crates/krishiv-connect-server/tests/auth.rs +++ b/crates/krishiv-connect-server/tests/auth.rs @@ -730,7 +730,7 @@ async fn execute_in(channel: &Channel, session_id: &str, token: &str) { while s.message().await.expect("response").is_some() {} } -/// A `Config` request of `ops` in [`SESSION`] as `token`. +/// A `Config` request of `op` in [`SESSION`] as `token`. async fn config_as( run: &Running, token: &str, @@ -752,7 +752,8 @@ async fn config_as( /// `Config` set and unset of an admin-only key or a server-scoped store key ask the session's /// policy as SQL `SET` does: a refusal is `PERMISSION_DENIED`, audited and counted under /// `connect` and `MANAGE` (ADR 0046 §8), a silent set turns it into a warning (still counted) -/// and changes nothing, and an admin may. +/// and changes nothing, an admin-only key named by its Spark alias is refused the same way, and +/// an admin may. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_refusals_are_counted_like_sql_set() { use krishiv_connect::proto::v4_3::config_request::{self as creq, operation::OpType as Op}; @@ -804,7 +805,19 @@ async fn config_refusals_are_counted_like_sql_set() { .await .expect_err("server-scoped store key"); assert_eq!(s.code(), Code::PermissionDenied); - assert_eq!(denied(&run), [(Surface::Connect, Privilege::Manage, 4)]); + // `krishiv.sql.autoBroadcastJoinThreshold` (admin) by its Spark name. + let s = config_as( + &run, + OPERATOR, + Op::Set(creq::Set { + pairs: vec![kv("spark.sql.autoBroadcastJoinThreshold", "1")], + silent: None, + }), + ) + .await + .expect_err("admin key by its Spark alias"); + assert_eq!(s.code(), Code::PermissionDenied); + assert_eq!(denied(&run), [(Surface::Connect, Privilege::Manage, 5)]); let got = config_as( &run, OPERATOR, @@ -816,11 +829,25 @@ async fn config_refusals_are_counted_like_sql_set() { .expect("get"); assert_ne!( got.pairs[0].value.as_deref(), - Some("1m"), + Some("1min"), "a refused set changes nothing" ); release_as(&run, OPERATOR).await; config_as(&run, ADMIN, set(false)).await.expect("admin may"); - assert_eq!(denied(&run), [(Surface::Connect, Privilege::Manage, 4)]); + let got = config_as( + &run, + ADMIN, + Op::GetOption(creq::GetOption { + keys: vec!["krishiv.resultCache.ttl".to_owned()], + }), + ) + .await + .expect("get"); + assert_eq!( + got.pairs[0].value.as_deref(), + Some("1min"), + "the admin's set holds (1m, read back normalized)" + ); + assert_eq!(denied(&run), [(Surface::Connect, Privilege::Manage, 5)]); } diff --git a/crates/krishiv-session/src/session/ops.rs b/crates/krishiv-session/src/session/ops.rs index 939d985fb..eba338dd2 100644 --- a/crates/krishiv-session/src/session/ops.rs +++ b/crates/krishiv-session/src/session/ops.rs @@ -117,6 +117,9 @@ impl Session { /// is stored, so a refused `SET` changes nothing. A `startup` key is refused by the /// configuration itself, for every principal (`CANNOT_MODIFY_STATIC_CONFIG`). /// + /// Public for server adapters that set configuration outside SQL (Spark Connect's `Config` + /// RPC), so they ask exactly what `SET` asks; not meant as user API. + /// /// # Errors /// `KRISHIV_INSUFFICIENT_PERMISSIONS` (42501) when the principal lacks `Manage`; the /// denial is audited and counted like every other. From 7cb082f49c66a19ef3b9bc4beec9fd2adee96d59 Mon Sep 17 00:00:00 2001 From: gopal tathe <44522021+techgopal@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:30:19 +0000 Subject: [PATCH 47/47] connect-server: test that the local user sets admin keys through Config (re-check R1) With authentication off the base session's policy allows everything: admin keys by name and by Spark alias and fs.* store keys are set, read back and unset. The removed role_of(local) == Admin assertion was the only test of it. Signed-off-by: gopal tathe <44522021+techgopal@users.noreply.github.com> --- crates/krishiv-connect-server/tests/rpcs.rs | 55 +++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/crates/krishiv-connect-server/tests/rpcs.rs b/crates/krishiv-connect-server/tests/rpcs.rs index 964a18ad6..ddc083541 100644 --- a/crates/krishiv-connect-server/tests/rpcs.rs +++ b/crates/krishiv-connect-server/tests/rpcs.rs @@ -814,6 +814,61 @@ async fn config_set_silent_and_static_keys() { ); } +/// Authentication off: the local user of the loopback listener sets and unsets admin-only keys +/// (by name and by Spark alias) and server-scoped store keys, as SQL `SET` lets it (the base +/// session's policy allows everything). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_the_local_user_sets_admin_keys() { + let run = start().await; + config( + &run, + OpType::Set(creq::Set { + pairs: vec![ + kv("krishiv.resultCache.ttl", Some("1m")), + kv("spark.sql.autoBroadcastJoinThreshold", Some("1")), + kv("fs.s3a.endpoint", Some("http://minio.local:9000")), + ], + silent: None, + }), + ) + .await + .expect("the local user may set admin keys"); + let got = config( + &run, + OpType::GetOption(creq::GetOption { + keys: keys(&[ + "krishiv.resultCache.ttl", + "spark.sql.autoBroadcastJoinThreshold", + "fs.s3a.endpoint", + ]), + }), + ) + .await + .expect("get"); + let values: Vec<_> = got.pairs.iter().map(|p| p.value.as_deref()).collect(); + assert_eq!( + values, + [Some("1min"), Some("1"), Some("http://minio.local:9000")] + ); + config( + &run, + OpType::Unset(creq::Unset { + keys: keys(&["krishiv.resultCache.ttl"]), + }), + ) + .await + .expect("the local user may unset an admin key"); + assert_eq!( + run.entry() + .session() + .conf() + .get("krishiv.resultCache.ttl") + .as_deref(), + Some("1d"), + "unset: back to the default" + ); +} + /// T-C8: a planted secret never comes back from `Config`. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_redaction() {