diff --git a/crates/fda/src/cli.rs b/crates/fda/src/cli.rs index dae2480c669..64e55da2b91 100644 --- a/crates/fda/src/cli.rs +++ b/crates/fda/src/cli.rs @@ -6,7 +6,8 @@ use uuid::Uuid; use crate::make_client; use feldera_rest_api::types::{ - ClusterMonitorEventFieldSelector, CompilationProfile, PipelineMonitorEventFieldSelector, + ClusterMonitorEventFieldSelector, CompilationProfile, MemberRole, + PipelineMonitorEventFieldSelector, }; /// Autocompletion for pipeline names by trying to fetch them from the server. @@ -28,6 +29,7 @@ fn pipeline_names(current: &std::ffi::OsStr) -> Vec { cli.auth, None, cli.timeout, + cli.tenant, ) else { return completions; }; @@ -162,6 +164,19 @@ pub struct Cli { help_heading = "Global Options" )] pub timeout: Option, + /// The tenant to act in, by name or id, sent as the `Feldera-Tenant` + /// header on every request. + /// + /// Needed when the credential may act in several tenants: a platform + /// owner, or a user who belongs to more than one tenant. An API key is + /// tenant-scoped and needs no selection. + #[arg( + long, + env = "FELDERA_TENANT", + global = true, + help_heading = "Global Options" + )] + pub tenant: Option, } #[derive(ValueEnum, Clone, Copy, Debug, PartialEq)] @@ -254,6 +269,14 @@ pub enum Commands { #[command(subcommand)] action: TenantActions, }, + /// Manage the members of the acting tenant and their roles. + /// + /// Acts in the tenant named by `--tenant`, or in the one your credential + /// resolves to without it. + Member { + #[command(subcommand)] + action: MemberActions, + }, /// Cluster information and status. Cluster { #[command(subcommand)] @@ -340,10 +363,95 @@ pub enum TrustRole { Admin, } +/// The roles a tenant membership may carry. `owner` is platform-wide rather +/// than a membership, so it is configured at deploy time and never assigned +/// here, which is why this cannot be a total mapping from the API's role type. +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum TenantMemberRole { + Read, + Write, + Admin, +} + +impl From for MemberRole { + fn from(role: TenantMemberRole) -> Self { + match role { + TenantMemberRole::Read => MemberRole::Read, + TenantMemberRole::Write => MemberRole::Write, + TenantMemberRole::Admin => MemberRole::Admin, + } + } +} + +/// Spelled as the API spells it, so printed output matches what the server +/// stores and what `fda member list` reads back. +impl Display for TenantMemberRole { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + TenantMemberRole::Read => "read", + TenantMemberRole::Write => "write", + TenantMemberRole::Admin => "admin", + }) + } +} + +#[derive(Subcommand)] +pub enum MemberActions { + /// List the members of the acting tenant and their roles. + List, + /// Grant a user access to the acting tenant, by identity. + /// + /// Works before the user's first login: the membership authorizes as soon + /// as that identity authenticates through the platform's identity + /// provider. The role is capped at your own. + Add { + /// OIDC subject of the user, matching the `sub` claim their identity + /// provider issues. + subject: String, + /// Role to grant: `read`, `write` or `admin`. + #[arg(long)] + role: TenantMemberRole, + /// Email, shown in the member list. Optional. + #[arg(long)] + email: Option, + }, + /// Change a member's role in the acting tenant. + SetRole { + /// Identifier of the user, as shown by `fda member list`. + user_id: Uuid, + /// New role: `read`, `write` or `admin`. + role: TenantMemberRole, + }, + /// Remove a member from the acting tenant. + /// + /// Whether this alone revokes access depends on the deployment: where a + /// login provisions memberships, the user is re-added at the default role + /// on their next login unless their identity provider stops resolving this + /// tenant for them. + #[clap(aliases = &["rm"])] + Remove { + /// Identifier of the user, as shown by `fda member list`. + user_id: Uuid, + }, +} + #[derive(Subcommand)] pub enum TenantActions { /// List every tenant in the installation. List, + /// Retrieve a single tenant by name or identifier. + Get { + /// The tenant's name, or its identifier as shown by `fda tenant list`. + tenant: String, + }, + /// Create a tenant, or return it if one with this name already exists. + /// + /// A login resolves its tenant by name, so a user whose identity provider + /// asserts this name lands in the tenant created here. + Create { + /// The name of the tenant. + name: String, + }, /// Rename a tenant. /// /// A login resolves its tenant by name, so the new name decides which users diff --git a/crates/fda/src/main.rs b/crates/fda/src/main.rs index 0268a903e07..d761d82cc5f 100644 --- a/crates/fda/src/main.rs +++ b/crates/fda/src/main.rs @@ -35,12 +35,14 @@ mod cli; mod debug; mod shell; mod tags; +mod util; pub(crate) const UPGRADE_NOTICE: &str = "Try upgrading to the latest CLI version to resolve this issue. Also make sure the pipeline is recompiled with the latest version of feldera. Report it on github.com/feldera/feldera if the issue persists."; use crate::adhoc::handle_adhoc_query; use crate::cli::*; use crate::shell::shell; +use crate::util::terminal_safe; /// Creates a unique filename by appending a number to the base name if it already exists. fn unique_file(base: &str, extension: &str) -> Result<(PathBuf, File), std::io::Error> { @@ -79,6 +81,7 @@ fn make_auth_headers(auth: &Option) -> Result, auth_token_command: Option, timeout: Option, + tenant: Option, ) -> Result> { let mut client_builder = reqwest::ClientBuilder::new().danger_accept_invalid_certs(insecure); @@ -118,19 +122,29 @@ pub(crate) fn make_client( } } - // Only execute the auth-token command if we are actually going to send the - // credentials, which is https alone. + // The tenant selector is not a credential and travels on any scheme; + // bearer credentials go over https alone. Only execute the auth-token + // command if the credentials will actually be sent. + let mut headers = HeaderMap::new(); + if let Some(tenant) = &tenant { + headers.insert( + "Feldera-Tenant", + HeaderValue::from_str(tenant) + .map_err(|e| format!("Invalid --tenant value `{tenant}`: {e}"))?, + ); + } if host.starts_with("https://") { let resolved_auth = match auth_token_command { Some(cmd) => Some(run_auth_token_command(&cmd)?), None => auth, }; - client_builder = client_builder.default_headers(make_auth_headers(&resolved_auth)?); + headers.extend(make_auth_headers(&resolved_auth)?); } else if host.starts_with("http://") && (auth.is_some() || auth_token_command.is_some()) { warn!( "The provided credentials are not added to the request because {host} does not use `https`." ); } + client_builder = client_builder.default_headers(headers); let client = client_builder.build()?; Ok(Client::new_with_client(host.as_str(), client)) @@ -238,7 +252,7 @@ fn handle_errors_fatal( Box::new(move |err: Error| -> Infallible { match err { Error::ErrorResponse(e) => { - eprintln!("{}", e.message); + eprintln!("{}", terminal_safe(&e.message)); debug!("Details: {:#?}", e.details); } Error::InvalidRequest(s) => { @@ -305,11 +319,24 @@ fn handle_errors_fatal( }); match server_msg { Some(m) => { - eprintln!("{msg}: {m}"); + eprintln!("{msg}: {}", terminal_safe(&m)); if status == StatusCode::UNAUTHORIZED && is_http { eprintln!("Did you mean to use https?"); } } + // A bodyless 401 is what the auth middleware answers when + // no credentials arrived at all; the version-mismatch + // notice would point in exactly the wrong direction. + None if status == StatusCode::UNAUTHORIZED => { + eprintln!("{msg}: authentication failed."); + if is_http { + eprintln!( + "fda sends credentials only over https, so this request carried none. Did you mean to use https?" + ); + } else { + eprintln!("Check that the API key or token is valid and not expired."); + } + } None => { warn!( "Unexpected error response from {server} -- this can happen if you're running different fda and feldera versions." @@ -364,7 +391,11 @@ async fn api_key_commands(format: OutputFormat, action: ApiKeyActions, client: C .unwrap(); match format { OutputFormat::Text => { - println!("API key '{}' created: {}", response.name, response.api_key); + println!( + "API key '{}' created: {}", + terminal_safe(&response.name), + response.api_key + ); } OutputFormat::Json => { println!( @@ -412,7 +443,7 @@ async fn api_key_commands(format: OutputFormat, action: ApiKeyActions, client: C rows.push(["name".to_string(), "role".to_string(), "id".to_string()]); for key in response.iter() { rows.push([ - key.name.to_string(), + terminal_safe(&key.name), key.role.to_string(), key.id.0.to_string(), ]); @@ -531,12 +562,12 @@ async fn oidc_trust_commands(format: OutputFormat, action: OidcTrustActions, cli ]]; for t in response.iter() { rows.push([ - t.name.clone(), + terminal_safe(&t.name), t.role.to_string(), - t.issuer.clone(), - t.subject.clone(), - t.audience.clone().unwrap_or_default(), - t.description.clone().unwrap_or_default(), + terminal_safe(&t.issuer), + terminal_safe(&t.subject), + terminal_safe(&t.audience.clone().unwrap_or_default()), + terminal_safe(&t.description.clone().unwrap_or_default()), ]); } println!( @@ -560,6 +591,127 @@ async fn oidc_trust_commands(format: OutputFormat, action: OidcTrustActions, cli } } +async fn member_commands(format: OutputFormat, action: MemberActions, client: Client) { + match action { + MemberActions::List => { + debug!("Listing tenant members"); + let response = client + .list_tenant_users() + .send() + .await + .map_err(handle_errors_fatal( + client.baseurl().clone(), + "Failed to list tenant members", + 1, + )) + .unwrap(); + match format { + OutputFormat::Text => { + let mut rows = vec![[ + "user id".to_string(), + "name".to_string(), + "subject".to_string(), + "email".to_string(), + "verified".to_string(), + "role".to_string(), + "origin".to_string(), + ]]; + for m in response.iter() { + rows.push([ + m.user_id.0.to_string(), + terminal_safe(&m.display_name.clone().unwrap_or_default()), + terminal_safe(&m.subject), + terminal_safe(&m.email.clone().unwrap_or_default()), + // Absent from an older manager's answer, which is + // the same as not verified. + if m.email_verified.unwrap_or(false) { + "yes" + } else { + "no" + } + .to_string(), + m.role.to_string(), + m.origin.as_ref().map(|o| o.to_string()).unwrap_or_default(), + ]); + } + println!( + "{}", + Builder::from_iter(rows).build().with(Style::rounded()) + ); + } + OutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&response.into_inner()) + .expect("Failed to serialize member list") + ); + } + _ => { + eprintln!("Unsupported output format: {}", format); + std::process::exit(1); + } + } + } + MemberActions::Add { + subject, + role, + email, + } => { + debug!("Adding member {subject}"); + let response = client + .add_tenant_user() + .body_map(|body| { + body.subject(subject.clone()) + .role(role) + .email(email.clone()) + }) + .send() + .await + .map_err(handle_errors_fatal( + client.baseurl().clone(), + "Failed to add tenant member", + 1, + )) + .unwrap(); + println!( + "Added '{subject}' as {role} (user id {}).", + response.user_id.0 + ); + } + MemberActions::SetRole { user_id, role } => { + debug!("Setting role for {user_id}"); + client + .put_tenant_user() + .user_id(user_id) + .body_map(|body| body.role(role)) + .send() + .await + .map_err(handle_errors_fatal( + client.baseurl().clone(), + "Failed to set member role", + 1, + )) + .unwrap(); + println!("User {user_id} is now {role}."); + } + MemberActions::Remove { user_id } => { + debug!("Removing member {user_id}"); + client + .delete_tenant_user() + .user_id(user_id) + .send() + .await + .map_err(handle_errors_fatal( + client.baseurl().clone(), + "Failed to remove tenant member", + 1, + )) + .unwrap(); + println!("Removed user {user_id} from the tenant."); + } + } +} + async fn tenant_commands(format: OutputFormat, action: TenantActions, client: Client) { match action { TenantActions::List => { @@ -583,9 +735,9 @@ async fn tenant_commands(format: OutputFormat, action: TenantActions, client: Cl ]]; for tenant in response.iter() { rows.push([ - tenant.name.clone(), + terminal_safe(&tenant.name), tenant.id.0.to_string(), - tenant.initial_provider.clone(), + terminal_safe(&tenant.initial_provider), ]); } println!( @@ -606,6 +758,94 @@ async fn tenant_commands(format: OutputFormat, action: TenantActions, client: Cl } } } + TenantActions::Get { tenant } => { + debug!("Retrieving tenant {tenant}"); + let response = client + .get_tenant() + .tenant_id(&tenant) + .send() + .await + .map_err(handle_errors_fatal( + client.baseurl().clone(), + "Failed to retrieve tenant", + 1, + )) + .unwrap(); + match format { + OutputFormat::Text => { + let rows = vec![ + [ + "name".to_string(), + "id".to_string(), + "initial provider".to_string(), + ], + [ + response.name.clone(), + response.id.0.to_string(), + response.initial_provider.clone(), + ], + ]; + println!( + "{}", + Builder::from_iter(rows).build().with(Style::rounded()) + ); + } + OutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&response.into_inner()) + .expect("Failed to serialize tenant") + ); + } + _ => { + eprintln!("Unsupported output format: {}", format); + std::process::exit(1); + } + } + } + TenantActions::Create { name } => { + debug!("Creating tenant {name}"); + let response = client + .create_tenant() + .body_map(|body| body.name(name.clone())) + .send() + .await + .map_err(handle_errors_fatal( + client.baseurl().clone(), + "Failed to create tenant", + 1, + )) + .unwrap(); + // 201 is a fresh creation; 200 returns the pre-existing tenant. + let created = response.status() == reqwest::StatusCode::CREATED; + match format { + OutputFormat::Text => { + if created { + println!( + "Created tenant '{}' ({}).", + terminal_safe(&response.name), + response.id.0 + ); + } else { + println!( + "Tenant '{}' already exists ({}).", + response.name, response.id.0 + ); + } + } + OutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&response.into_inner()) + .expect("Failed to serialize tenant") + ); + } + _ => { + eprintln!("Unsupported output format: {}", format); + std::process::exit(1); + } + } + } TenantActions::Rename { tenant_id, name, @@ -668,7 +908,7 @@ async fn pipelines(format: OutputFormat, client: Client) { rows.push(["name".to_string(), "status".to_string()]); for pipeline in response.iter() { rows.push([ - pipeline.name.to_string(), + terminal_safe(&pipeline.name), pipeline.deployment_status.to_string(), ]); } @@ -916,7 +1156,7 @@ async fn wait_for_status_one_of( .unwrap_or_default() ); } else { - eprintln!("{}", deployment_error.message); + eprintln!("{}", terminal_safe(&deployment_error.message)); } std::process::exit(1); } @@ -3487,6 +3727,7 @@ fn main() { cli.auth, cli.auth_token_command, cli.timeout, + cli.tenant, ) .map_err(|e| { eprintln!("Failed to create HTTP client: {}", e); @@ -3501,6 +3742,7 @@ fn main() { oidc_trust_commands(cli.format, action, client()).await } Commands::Tenant { action } => tenant_commands(cli.format, action, client()).await, + Commands::Member { action } => member_commands(cli.format, action, client()).await, Commands::Pipelines => pipelines(cli.format, client()).await, Commands::Pipeline(action) => pipeline(cli.format, action, client()).await, Commands::ValidateProgram { @@ -3565,6 +3807,7 @@ aC3Oy4iVrYGOq9v6uP9iblE=\n\ None, None, None, + None, ) .expect_err("non-existent cert path must produce an error"); let msg = err.to_string(); @@ -3586,6 +3829,7 @@ aC3Oy4iVrYGOq9v6uP9iblE=\n\ None, None, None, + None, ) .expect_err("garbage cert contents must produce an error"); let msg = err.to_string(); @@ -3613,6 +3857,7 @@ aC3Oy4iVrYGOq9v6uP9iblE=\n\ None, None, None, + None, ); // Some rustls backends reject the synthetic cert at build time; accept // either a successful build or a cert-validation error, but never the diff --git a/crates/fda/src/util.rs b/crates/fda/src/util.rs new file mode 100644 index 00000000000..5713fdf22e4 --- /dev/null +++ b/crates/fda/src/util.rs @@ -0,0 +1,81 @@ +//! Shared helpers for rendering what the API returns. + +/// Render text the API returned so a terminal displays it rather than obeys it. +/// +/// A table cell goes straight to stdout, and much of what fills one is written +/// by somebody else: a member's name and email come from the identity provider, +/// a tenant's name and a trust's description come from whoever created them. An +/// escape sequence in that text is acted on by the terminal instead of shown, +/// which lets one user redraw what an administrator sees, or reach whatever +/// else that terminal binds to a control sequence. +/// +/// Every control character is replaced, along with the bidirectional overrides +/// that reorder a line without leaving a visible trace. Ordinary text, accents +/// and scripts of every direction included, is left alone. `--format json` +/// bypasses this and reports exactly what the server said, escaped by the JSON +/// encoder. +/// +/// Not for pipeline logs or query results, which are the program's own output +/// and are meant to arrive verbatim. +pub fn terminal_safe(text: &str) -> String { + text.chars() + .map(|c| match c { + // C0, DEL and C1. `char::is_control` covers all three. + c if c.is_control() => char::REPLACEMENT_CHARACTER, + // Explicit bidirectional embedding, override and isolate + // (Unicode Annex #9): they change the reading order of what + // follows, which is how a name can misrepresent another one. + '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' => char::REPLACEMENT_CHARACTER, + c => c, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::terminal_safe; + + /// Text the API returns is displayed, never obeyed: control and + /// bidirectional-override characters must not reach the terminal. + #[test] + fn table_text_cannot_carry_terminal_control_sequences() { + // A colour change, a cursor move, a clipboard write, and a line the + // renderer would otherwise split into two. + for hostile in [ + "\u{1b}[31mroot\u{1b}[0m", + "\u{1b}[2J\u{1b}[H", + "\u{1b}]52;c;bWFsaWNl\u{7}", + "admin\r\nfake row", + "bell\u{7}", + "nul\u{0}byte", + "c1\u{9b}31m", + "\u{202e}moc.live@nimda", + ] { + let safe = terminal_safe(hostile); + assert!( + !safe.chars().any(|c| c.is_control() + || ('\u{202A}'..='\u{202E}').contains(&c) + || ('\u{2066}'..='\u{2069}').contains(&c)), + "{safe:?} still carries a control character" + ); + assert_eq!(safe.chars().count(), hostile.chars().count()); + } + } + + /// Ordinary names survive intact, whatever script they are written in. + #[test] + fn table_text_leaves_ordinary_names_alone() { + for benign in [ + "Ada Lovelace", + "ada@example.com", + "Gerd Zellweger", + "Ólafur Þórðarson", + "\u{5f20}\u{4f1f}", + "\u{645}\u{62d}\u{645}\u{62f}", + "tenant-1_a.b", + "", + ] { + assert_eq!(terminal_safe(benign), benign); + } + } +} diff --git a/crates/pipeline-manager/migrations/V36__membership_provenance_and_user_profile.sql b/crates/pipeline-manager/migrations/V36__membership_provenance_and_user_profile.sql new file mode 100644 index 00000000000..3659dd5afbc --- /dev/null +++ b/crates/pipeline-manager/migrations/V36__membership_provenance_and_user_profile.sql @@ -0,0 +1,31 @@ +-- Provenance for tenant memberships: when a row was created and which path +-- created it. `origin` is 'claim' (the user's token listed the tenant), +-- 'derived' (the personal or issuer tenant derivation), or 'api' (granted +-- through the RBAC endpoints). Rows from before this migration keep NULL in +-- both columns: their provenance is unknown. +ALTER TABLE tenant_membership ADD COLUMN created_at timestamptz; +ALTER TABLE tenant_membership ADD COLUMN origin varchar + CHECK (origin IN ('claim', 'derived', 'api')); + +-- Profile fields the identity provider owns, refreshed from its OIDC UserInfo +-- endpoint. `app_user.email` already existed but carried no indication of where +-- it came from: an access-token claim, an administrator typing it into the +-- pre-provisioning API, or the provider itself. `email_verified` supplies that +-- distinction, so an interface can mark an address the provider vouches for and +-- leave every other one unmarked. +-- +-- Verification is false until a provider says otherwise, so that a provider +-- saying nothing never reads as an endorsement. The rest stay NULL until the +-- first refresh runs, and for providers publishing no `userinfo_endpoint`. +ALTER TABLE app_user ADD COLUMN email_verified boolean NOT NULL DEFAULT false; +ALTER TABLE app_user ADD COLUMN display_name varchar; + +-- When the last refresh attempt ran, successful or not, so a provider that +-- fails or answers with nothing is retried on a schedule rather than on every +-- request. +ALTER TABLE app_user ADD COLUMN profile_refreshed_at timestamptz; + +-- The `auth_time` of the token that last attempt covered. A token carrying a +-- newer one means the user authenticated again, which is the moment a changed +-- email can appear, so it forces a refresh ahead of the schedule. +ALTER TABLE app_user ADD COLUMN profile_auth_time bigint; diff --git a/crates/pipeline-manager/src/api/endpoints/config.rs b/crates/pipeline-manager/src/api/endpoints/config.rs index 1835726701f..5a3cdb35949 100644 --- a/crates/pipeline-manager/src/api/endpoints/config.rs +++ b/crates/pipeline-manager/src/api/endpoints/config.rs @@ -1,6 +1,6 @@ // Configuration API to retrieve the current authentication configuration and list of demos use actix_web::{ - HttpRequest, HttpResponse, get, + HttpMessage, HttpRequest, HttpResponse, get, web::{Data as WebData, ReqData}, }; use feldera_cloud1_client::license::DisplaySchedule; @@ -8,10 +8,11 @@ use serde::Serialize; use utoipa::ToSchema; use crate::api::main::ServerState; -use crate::auth::AuthenticatedPrincipal; +use crate::auth::{AuthenticatedPrincipal, LoginIdentity, UnresolvedActingTenant}; use crate::db::storage::Storage; use crate::db::types::role::Role; use crate::db::types::tenant::TenantId; +use crate::db::types::user::UserMembership; use crate::error::ManagerError; use crate::license::{LicenseCheck, LicenseValidity}; use crate::unstable_features; @@ -221,14 +222,22 @@ pub(crate) async fn get_config_demos( #[derive(Serialize, ToSchema)] pub(crate) struct SessionInfo { - /// Current user's tenant ID - pub tenant_id: TenantId, - /// Current user's tenant name - pub tenant_name: String, - /// The caller's effective role in the acting tenant. The web console uses - /// this to gate the admin UI (no role claim lives in the JWT). A platform - /// owner reports `owner` here. - pub role: Role, + /// Acting tenant id. `null` when the login resolved no acting tenant: the + /// user belongs to several tenants, or none, and sent no `Feldera-Tenant` + /// header. Pick a tenant from `memberships` and repeat requests with the + /// header. + pub tenant_id: Option, + /// Acting tenant name; `null` exactly when `tenant_id` is. + pub tenant_name: Option, + /// The caller's effective role in the acting tenant; `null` exactly when + /// `tenant_id` is. The web console uses this to gate the admin UI (no role + /// claim lives in the JWT). A platform owner reports `owner` here. + pub role: Option, + /// The tenants this user may act in, from the membership table. Empty for + /// principals that are not human logins (API keys, federated workloads, + /// no-auth mode) and for platform owners without explicit memberships, + /// who act in any tenant regardless. + pub memberships: Vec, } impl SessionInfo { @@ -236,12 +245,14 @@ impl SessionInfo { state: &ServerState, tenant_id: TenantId, role: Role, + memberships: Vec, ) -> Result { let tenant_name = state.db.lock().await.get_tenant_name(tenant_id).await?; Ok(SessionInfo { - tenant_id, - tenant_name, - role, + tenant_id: Some(tenant_id), + tenant_name: Some(tenant_name), + role: Some(role), + memberships, }) } } @@ -249,6 +260,11 @@ impl SessionInfo { /// Get Session /// /// Retrieve login session information for your current user session. +/// +/// This is the one route that answers a login without a resolved acting +/// tenant: when the user belongs to several tenants (or none) and no +/// `Feldera-Tenant` header selects one, the acting-tenant fields are `null` +/// and `memberships` lists the tenants to pick from. #[utoipa::path( context_path = "/v0", security(("JSON web token (JWT) or API key" = [])), @@ -268,8 +284,30 @@ pub(crate) async fn get_config_session( state: WebData, tenant_id: ReqData, principal: ReqData, + req: HttpRequest, ) -> Result { - let session_info = SessionInfo::gather(&state, *tenant_id, principal.role).await?; + let identity = req.extensions().get::().cloned(); + let memberships = match &identity { + Some(identity) => { + state + .db + .lock() + .await + .list_user_memberships(&identity.provider, &identity.subject) + .await? + } + None => vec![], + }; + let session_info = if req.extensions().get::().is_some() { + SessionInfo { + tenant_id: None, + tenant_name: None, + role: None, + memberships, + } + } else { + SessionInfo::gather(&state, *tenant_id, principal.role, memberships).await? + }; Ok(HttpResponse::Ok().json(session_info)) } diff --git a/crates/pipeline-manager/src/api/endpoints/tenant.rs b/crates/pipeline-manager/src/api/endpoints/tenant.rs index 5333077319e..8547ba833ac 100644 --- a/crates/pipeline-manager/src/api/endpoints/tenant.rs +++ b/crates/pipeline-manager/src/api/endpoints/tenant.rs @@ -12,7 +12,7 @@ use crate::db::error::DBError; use crate::db::storage::Storage; use crate::db::types::role::Role; use crate::db::types::tenant::TenantId; -use crate::db::types::user::{TenantInfo, UserId}; +use crate::db::types::user::{MembershipOrigin, TenantInfo, UserId}; use crate::error::ManagerError; use actix_web::{ HttpRequest, HttpResponse, delete, get, @@ -21,7 +21,7 @@ use actix_web::{ web::{self, Data as WebData, ReqData}, }; use serde::{Deserialize, Serialize}; -use tracing::info; +use tracing::{debug, info}; use utoipa::ToSchema; use uuid::Uuid; @@ -192,7 +192,7 @@ pub(crate) async fn put_tenant_user( .db .lock() .await - .upsert_member_role(*tenant_id, user_id, requested) + .upsert_member_role(*tenant_id, user_id, requested, MembershipOrigin::Api) .await?; info!( "Set role {requested} for user {user_id} (tenant: {})", @@ -239,9 +239,11 @@ pub(crate) async fn delete_tenant_user( /// Provision Tenant Member /// /// Add a member to the acting tenant by identity, before the user's first -/// login. The grant is dormant until that identity authenticates into the -/// tenant through the IdP. The role is capped at the caller's own role and may -/// not be `owner`. +/// login. The membership authorizes on its own: as soon as that identity +/// authenticates through the platform's identity provider, the user may act +/// in this tenant, and a headerless login with exactly this one membership +/// lands in it. The role is capped at the caller's own role and may not be +/// `owner`. #[utoipa::path( context_path = "/v0", security(("JSON web token (JWT) or API key" = [])), @@ -293,13 +295,6 @@ pub(crate) async fn add_tenant_user( .json(&AddMemberResponse { user_id })) } -/// Response to a successful tenant creation. -#[derive(Debug, Serialize, ToSchema)] -pub(crate) struct NewTenantResponse { - pub id: TenantId, - pub name: String, -} - /// Rename Tenant /// /// Change a tenant's name. Only the name changes: pipelines, API keys, members @@ -407,20 +402,47 @@ pub(crate) async fn list_tenants( .json(&tenants)) } +/// Get Tenant +/// +/// Retrieve a single tenant by name or identifier. A selector that parses as a +/// UUID is looked up by tenant identifier, otherwise by name. +#[utoipa::path( + context_path = "/v0", + security(("JSON web token (JWT) or API key" = [])), + params(("tenant_id" = String, Path, description = "Tenant name or identifier (UUID)")), + responses( + (status = OK, description = "Tenant retrieved", body = TenantInfo), + (status = FORBIDDEN, description = "Caller is not a platform owner", body = ErrorResponse), + (status = NOT_FOUND, description = "No tenant with that name or identifier", body = ErrorResponse), + (status = INTERNAL_SERVER_ERROR, body = ErrorResponse) + ), + tag = "Platform" +)] +#[get("/tenants/{tenant_id}")] +pub(crate) async fn get_tenant( + state: WebData, + req: HttpRequest, +) -> Result { + let selector = parse_url_parameter(&req, "tenant_id")?; + let tenant = state.db.lock().await.get_tenant(&selector).await?; + Ok(HttpResponse::Ok() + .insert_header(CacheControl(vec![CacheDirective::NoCache])) + .json(&tenant)) +} + /// Create Tenant /// /// Explicitly create a tenant, rather than relying on first login. /// A login resolves its tenant by name, so a user whose identity provider -/// asserts this name lands in the tenant created here. Fails with a conflict if -/// the name is already taken. +/// asserts this name lands in the tenant created here. #[utoipa::path( context_path = "/v0", security(("JSON web token (JWT) or API key" = [])), request_body = NewTenantRequest, responses( - (status = CREATED, description = "Tenant created", body = NewTenantResponse), + (status = CREATED, description = "Tenant created", body = TenantInfo), + (status = OK, description = "Tenant with that name already exists", body = TenantInfo), (status = FORBIDDEN, description = "Caller is not a platform owner", body = ErrorResponse), - (status = CONFLICT, description = "A tenant with that name already exists", body = ErrorResponse), (status = INTERNAL_SERVER_ERROR, body = ErrorResponse) ), tag = "Platform" @@ -440,20 +462,23 @@ pub(crate) async fn create_tenant( .app_data::() .map(|c| c.provider.issuer().to_string()) .unwrap_or_else(|| "manual".to_string()); - let id = state + let (tenant, created) = state .db .lock() .await - .create_tenant(Uuid::now_v7(), &body.name, &provider) + .get_or_create_tenant(Uuid::now_v7(), &body.name, &provider) .await?; - info!( - "Created tenant '{}' ({id}, provider: {provider})", - body.name - ); - Ok(HttpResponse::Created() + let mut response = if created { + info!( + "Created tenant '{}' ({}, provider: {provider})", + tenant.name, tenant.id + ); + HttpResponse::Created() + } else { + debug!("Tenant '{}' ({}) already exists", tenant.name, tenant.id); + HttpResponse::Ok() + }; + Ok(response .insert_header(CacheControl(vec![CacheDirective::NoCache])) - .json(&NewTenantResponse { - id, - name: body.name, - })) + .json(&tenant)) } diff --git a/crates/pipeline-manager/src/api/error.rs b/crates/pipeline-manager/src/api/error.rs index 2ac7ddb4da6..407f1e84631 100644 --- a/crates/pipeline-manager/src/api/error.rs +++ b/crates/pipeline-manager/src/api/error.rs @@ -1,6 +1,4 @@ -use actix_web::{ - HttpResponse, HttpResponseBuilder, ResponseError, body::BoxBody, http::StatusCode, -}; +use actix_web::{HttpResponse, ResponseError, body::BoxBody, http::StatusCode}; use feldera_types::error::{DetailedError, ErrorResponse}; use serde::Serialize; use std::time::Duration; @@ -201,6 +199,6 @@ impl ResponseError for ApiError { } fn error_response(&self) -> HttpResponse { - HttpResponseBuilder::new(self.status_code()).json(ErrorResponse::from_error(self)) + crate::error::json_error_response(self) } } diff --git a/crates/pipeline-manager/src/api/main.rs b/crates/pipeline-manager/src/api/main.rs index a05b3fd1d8d..01a3122c1c3 100644 --- a/crates/pipeline-manager/src/api/main.rs +++ b/crates/pipeline-manager/src/api/main.rs @@ -7,6 +7,8 @@ use crate::db::probe::DbProbe; use crate::db::storage_postgres::StoragePostgres; use crate::error::ManagerError; use crate::license::LicenseCheck; +use crate::oidc::fetch::OidcClients; +use crate::oidc::userinfo::UserProfileCache; use crate::runner::interaction::RunnerInteraction; use crate::unstable_features; use actix_http::StatusCode; @@ -269,6 +271,7 @@ It contains the following fields: endpoints::tenant::put_tenant_user, endpoints::tenant::delete_tenant_user, endpoints::tenant::list_tenants, + endpoints::tenant::get_tenant, endpoints::tenant::create_tenant, endpoints::tenant::patch_tenant, endpoints::tenant::delete_tenant @@ -357,13 +360,14 @@ It contains the following fields: crate::db::types::user::UserId, crate::db::types::user::TenantMember, crate::db::types::user::TenantInfo, + crate::db::types::user::MembershipOrigin, + crate::db::types::user::UserMembership, crate::api::endpoints::tenant::SetMemberRoleRequest, crate::api::endpoints::tenant::AddMemberRequest, crate::api::endpoints::tenant::AddMemberResponse, crate::api::endpoints::tenant::NewTenantRequest, crate::api::endpoints::tenant::RenameTenantRequest, crate::api::endpoints::tenant::RenameTenantResponse, - crate::api::endpoints::tenant::NewTenantResponse, // API key crate::db::types::api_key::ApiKeyId, @@ -783,6 +787,7 @@ fn api_scope() -> Scope { .service(endpoints::tenant::put_tenant_user) .service(endpoints::tenant::delete_tenant_user) .service(endpoints::tenant::list_tenants) + .service(endpoints::tenant::get_tenant) .service(endpoints::tenant::create_tenant) .service(endpoints::tenant::patch_tenant) .service(endpoints::tenant::delete_tenant) @@ -864,9 +869,16 @@ pub(crate) struct ServerState { pub config: ApiServerConfig, pub jwk_cache: Arc>, pub issuer_jwk_cache: Arc>, + /// Which logins have had their profile read from the identity provider + /// recently, and where each issuer's UserInfo endpoint lives. + pub user_profiles: Arc>, /// Roots an OIDC fetch trusts on top of the platform's, read once here so /// that authenticating a request never touches the filesystem. pub oidc_root_certs: Vec, + /// The HTTP clients OIDC fetches use, built once: constructing one loads + /// the platform's root certificate store, which is far too slow to do per + /// request. + pub oidc_clients: OidcClients, probe: Arc>, pub demos: Vec, pub license_check: Arc>>, @@ -890,6 +902,8 @@ impl ServerState { config, jwk_cache: Arc::new(Mutex::new(JwkCache::new())), issuer_jwk_cache: Arc::new(Mutex::new(IssuerJwkCache::new())), + user_profiles: Arc::new(Mutex::new(UserProfileCache::new())), + oidc_clients: OidcClients::new(&oidc_root_certs)?, oidc_root_certs, probe: DbProbe::new(db_copy).await, demos, @@ -953,6 +967,9 @@ pub async fn run( api_config: ApiServerConfig, license_check: Arc>>, ) -> AnyResult<()> { + if let Err(reason) = api_config.validate_authorization() { + return Err(anyhow::anyhow!(reason)); + } let listener = TcpListener::bind((common_config.bind_address.clone(), common_config.api_port)) .unwrap_or_else(|_| { panic!( diff --git a/crates/pipeline-manager/src/api/rbac.rs b/crates/pipeline-manager/src/api/rbac.rs index acf5b2c3e26..a2503755721 100644 --- a/crates/pipeline-manager/src/api/rbac.rs +++ b/crates/pipeline-manager/src/api/rbac.rs @@ -18,7 +18,7 @@ use actix_web::middleware::Next; use actix_web::{HttpMessage, HttpResponse, ResponseError}; use std::collections::HashMap; use std::sync::OnceLock; -use tracing::{debug, error, info}; +use tracing::{debug, error}; /// Minimum role required to reach each `/v0` route. A `(method, path)` absent /// from this table is denied by the middleware. @@ -99,6 +99,7 @@ static ROUTE_MIN_ROLE: &[(&str, &str, Role)] = &[ ("DELETE", "/v0/tenant/users/{user_id}", Role::Admin), // delete_tenant_user ("GET", "/v0/tenants", Role::Owner), // list_tenants ("POST", "/v0/tenants", Role::Owner), // create_tenant + ("GET", "/v0/tenants/{tenant_id}", Role::Owner), // get_tenant ("PATCH", "/v0/tenants/{tenant_id}", Role::Owner), // patch_tenant ("DELETE", "/v0/tenants/{tenant_id}", Role::Owner), // delete_tenant ]; @@ -261,25 +262,12 @@ fn authorize( } /// Record who reached which route, in which tenant, at what role. -/// -/// Privileged access is logged unconditionally: `admin` manages a tenant's -/// members and trusts, and `owner` acts across tenants, including in tenants it -/// is not a member of, so those are the requests an operator has to be able to -/// account for after the fact. Read and write traffic is the data plane, one -/// line per request, and stays at debug. fn audit(method: &Method, pattern: &str, principal: Option<&AuthenticatedPrincipal>) { let Some(p) = principal else { return }; - if p.role >= Role::Admin { - info!( - "audit: user='{}' tenant={} role={} {} {}", - p.label, p.acting_tenant, p.role, method, pattern - ); - } else { - debug!( - "audit: user='{}' tenant={} role={} {} {}", - p.label, p.acting_tenant, p.role, method, pattern - ); - } + debug!( + "audit: user='{}' tenant={} role={} {} {}", + p.label, p.acting_tenant, p.role, method, pattern + ); } /// Refuse any `/v0` request whose principal is below the role its route @@ -500,6 +488,7 @@ mod test { // Platform administration is owner. expect("GET", "/v0/tenants", Role::Owner); expect("POST", "/v0/tenants", Role::Owner); + expect("GET", "/v0/tenants/{tenant_id}", Role::Owner); } /// End-to-end through a real actix pipeline: the middleware short-circuits diff --git a/crates/pipeline-manager/src/auth.rs b/crates/pipeline-manager/src/auth.rs index dd6688f6774..4f0a5158c41 100644 --- a/crates/pipeline-manager/src/auth.rs +++ b/crates/pipeline-manager/src/auth.rs @@ -81,8 +81,13 @@ use crate::db::storage::Storage; use crate::db::storage_postgres::StoragePostgres; use crate::db::types::role::Role; use crate::db::types::tenant::TenantId; +use crate::db::types::user::{MembershipOrigin, UserMembership}; use crate::oidc::fetch::{ - OidcDestination, fetch_issuer_jwks, fetch_jwks_uri_from_discovery, oidc_http_client, + OidcClients, OidcDestination, fetch_issuer_jwks, fetch_jwks_uri_from_discovery, +}; +use crate::oidc::userinfo::{ + PROFILE_REFRESH_TTL_SECONDS, deserialize_bool_or_string, fetch_user_profile, + resolve_userinfo_endpoint, }; use reqwest::Certificate; @@ -119,6 +124,27 @@ impl AuthenticatedPrincipal { } } +/// Identity of a human login, installed by `bearer_auth` as a request +/// extension so the session endpoint can look up the user's memberships. +/// Absent for API keys, federated workloads, and no-auth mode. +#[derive(Clone, Debug)] +pub(crate) struct LoginIdentity { + pub provider: String, + pub subject: String, +} + +/// Marker extension: the login carried no resolvable acting tenant. Only the +/// session endpoint sees such a request; every other route was refused. The +/// session handler answers with the caller's memberships and no acting-tenant +/// fields, so a client can pick a tenant. +#[derive(Clone, Copy, Debug)] +pub(crate) struct UnresolvedActingTenant; + +/// Whether this request may be answered without a resolved acting tenant. +fn is_session_request(req: &ServiceRequest) -> bool { + req.method() == actix_web::http::Method::GET && req.path() == "/v0/config/session" +} + /// Returns true if the given OIDC identity is configured as a platform owner. /// Matches an `owners` entry against the email, the bare subject, or the /// provider-qualified `" "` form. Callers must pass `email` @@ -501,6 +527,231 @@ async fn resolve_owner_acting_tenant( } } +/// The owner's home tenant: named by its claims, the default tenant when they +/// name none or, with provisioning off, when the named tenant does not exist. +/// A login with provisioning off must not create tenants, an owner's included. +async fn owner_home_tenant( + state: &ServerState, + token_data: &TokenData, + provider: &str, +) -> Result { + // The Feldera-Tenant header selects the acting tenant for an owner, so + // the home resolution deliberately reads the claims with no headers. + let empty = actix_web::http::header::HeaderMap::new(); + let Ok(name) = token_data.tenant_name(&state.config, &empty) else { + return Ok(DEFAULT_TENANT_ID); + }; + let db = state.db.lock().await; + if state.config.provision_on_login { + db.get_or_create_tenant_id(Uuid::now_v7(), name, provider.to_string()) + .await + } else { + // By name alone: the name came from the claims, and one that parses + // as a UUID must not be reinterpreted as a tenant id. + Ok(db + .find_tenant_id_by_name(&name) + .await? + .unwrap_or(DEFAULT_TENANT_ID)) + } +} + +/// Provision what the tenancy strategy implies for this login, when +/// `provision_on_login` allows it. The deliberately selected claim entry is +/// fully provisioned (get-or-create, enroll, `first_user_role` on creation); +/// every other listed entry enrolls into an existing tenant only, so a +/// mangled claim entry cannot mint a tenant with the logger-in as its admin. +/// With provisioning off, only the user's identity row is refreshed (email), +/// so member lists stay readable; tenant creation and enrollment stop. +async fn provision_login( + state: &ServerState, + token_data: &TokenData, + selector: Option<&str>, + provider: &str, + subject: &str, + email: Option<&str>, +) -> Result<(), DBError> { + if !state.config.provision_on_login { + let db = state.db.lock().await; + db.get_or_create_user(Uuid::now_v7(), provider, subject, email) + .await?; + return Ok(()); + } + let Some(listed) = token_data.authorized_tenants() else { + // The token names no tenant: derive one or, with both derivations + // off, provision nothing. That is no error: the user may hold + // API-granted memberships, and a user without any is denied by the + // acting-tenant selection, not here. + let Some(name) = derived_tenant_name(&state.config, provider, subject) else { + return Ok(()); + }; + let db = state.db.lock().await; + db.resolve_login( + Uuid::now_v7(), + Uuid::now_v7(), + name, + provider.to_string(), + subject.to_string(), + email.map(str::to_string), + state.config.default_role, + state.config.first_user_role, + MembershipOrigin::Derived, + ) + .await?; + return Ok(()); + }; + let candidate = match selector { + Some(sel) => listed.iter().find(|n| n.as_str() == sel).cloned(), + None if listed.len() == 1 => Some(listed[0].clone()), + None => None, + }; + let db = state.db.lock().await; + if let Some(name) = &candidate { + db.resolve_login( + Uuid::now_v7(), + Uuid::now_v7(), + name.clone(), + provider.to_string(), + subject.to_string(), + email.map(str::to_string), + state.config.default_role, + state.config.first_user_role, + MembershipOrigin::Claim, + ) + .await?; + } + let others: Vec = listed + .iter() + .filter(|n| Some(*n) != candidate.as_ref()) + .cloned() + .collect(); + if !others.is_empty() { + db.enroll_in_existing_tenants( + Uuid::now_v7(), + provider, + subject, + email, + &others, + state.config.default_role, + MembershipOrigin::Claim, + ) + .await?; + } + Ok(()) +} + +/// Ask the identity provider what it holds for this login, when that is due: +/// after one of `auth_time` or [`PROFILE_REFRESH_TTL_SECONDS`] expires. See +/// [`crate::oidc::userinfo`] for the reasoning. +/// +/// Display data only, so fetch detached. +async fn refresh_profile_if_due( + state: &Data, + token_data: &TokenData, + provider: &str, + subject: &str, + token: &str, +) { + let auth_time = token_data.claims.auth_time; + let due = state + .user_profiles + .lock() + .await + .claim_refresh(provider, subject, auth_time); + if !due { + return; + } + let state = state.clone(); + let issuer = token_data.claims.iss.clone(); + let provider = provider.to_string(); + let subject = subject.to_string(); + let token = token.to_string(); + tokio::spawn(async move { + if let Err(e) = + refresh_user_profile(&state, &issuer, &provider, &subject, &token, auth_time).await + { + // The recorded attempt holds off the next try, so a provider + // without a UserInfo endpoint says this once a day, not per login. + debug!("Profile refresh for '{subject}' at '{issuer}' did not complete: {e}"); + } + }); +} + +/// Read this identity's profile from the provider and store it. The issuer is +/// the configured login provider, which the operator named, so its fetches are +/// [`OidcDestination::OperatorConfigured`] like its JWKS fetches. +async fn refresh_user_profile( + state: &ServerState, + issuer: &str, + provider: &str, + subject: &str, + token: &str, + auth_time: Option, +) -> anyhow::Result<()> { + let claimed = state + .db + .lock() + .await + .claim_profile_refresh( + Uuid::now_v7(), + provider, + subject, + auth_time, + PROFILE_REFRESH_TTL_SECONDS as i64, + ) + .await?; + if !claimed { + return Ok(()); + } + let destination = OidcDestination::OperatorConfigured; + let endpoint = resolve_userinfo_endpoint( + &state.user_profiles, + issuer, + destination, + &state.oidc_clients, + ) + .await?; + let profile = + fetch_user_profile(&endpoint, subject, token, destination, &state.oidc_clients).await?; + state + .db + .lock() + .await + .store_user_profile(provider, subject, &profile) + .await?; + Ok(()) +} + +/// Pick the acting tenant from the user's memberships: a selector must name a +/// tenant the user is a member of, and without one a sole membership is +/// unambiguous. One neutral answer covers an unknown tenant and a tenant the +/// user is no member of, because distinguishing them would let any +/// authenticated user probe which tenants exist. Only the miss folds in; an +/// infrastructure error must surface as one, not as an authorization denial. +async fn select_acting_tenant( + state: &ServerState, + selector: Option<&str>, + memberships: &[UserMembership], +) -> Result<(TenantId, Role), DBError> { + let Some(sel) = selector else { + return match memberships { + [only] => Ok((only.tenant_id, only.role)), + [] => Err(DBError::NoTenantMemberships), + _ => Err(DBError::AmbiguousTenantMembership), + }; + }; + let tenant = match state.db.lock().await.get_tenant(sel).await { + Ok(tenant) => Some(tenant), + Err(DBError::UnknownTenantName { .. }) => None, + Err(e) => return Err(e), + }; + tenant + .and_then(|tenant| memberships.iter().find(|m| m.tenant_id == tenant.id)) + .map(|m| (m.tenant_id, m.role)) + .ok_or(DBError::NotATenantMember { + tenant: sel.to_string(), + }) +} + async fn bearer_auth( req: ServiceRequest, token: &str, @@ -530,9 +781,20 @@ async fn bearer_auth( let subject = token_data.claims.sub.clone(); let email = token_data.claims.email.clone(); let label = email.clone().unwrap_or_else(|| subject.clone()); + // Human logins carry their identity to the session endpoint, + // which reports the user's memberships to drive tenant selection. + req.extensions_mut().insert(LoginIdentity { + provider: provider.clone(), + subject: subject.clone(), + }); + // Owners take an early return below, and a token need not carry an + // email claim at all, so this sits ahead of both. It deliberately + // does not feed owner matching: that must not turn on whether a + // background fetch has landed yet. + refresh_profile_if_due(&state, &token_data, &provider, &subject, token).await; // Only a provider-verified email may match an owner entry; an // unverified, user-settable email must never confer `owner`. - let verified_email = if token_data.claims.email_verified == Some(true) { + let verified_email = if token_data.claims.email_verified { email.as_deref() } else { None @@ -550,29 +812,16 @@ async fn bearer_auth( ); if is_owner { - // The owner's home tenant comes from its claims, ignoring the - // Feldera-Tenant header (which selects the acting tenant for an - // owner). Falls back to the default tenant when claims name none. - let empty = actix_web::http::header::HeaderMap::new(); - let home = match token_data.tenant_name(&state.config, &empty) { - Ok(name) => { - let db = state.db.lock().await; - match db - .get_or_create_tenant_id(Uuid::now_v7(), name, provider.clone()) - .await - { - Ok(t) => t, - Err(e) => { - return Err(( - create_authz_json_error(&format!( - "Database error while fetching tenant: {e}" - )), - req, - )); - } - } + let home = match owner_home_tenant(&state, &token_data, &provider).await { + Ok(home) => home, + Err(e) => { + return Err(( + create_authz_json_error(&format!( + "Database error while fetching tenant: {e}" + )), + req, + )); } - Err(_) => DEFAULT_TENANT_ID, }; let acting = { let db = state.db.lock().await; @@ -591,52 +840,72 @@ async fn bearer_auth( return Ok(req); } - // Non-owner: the header disambiguates among the authorized tenants - // (existing behavior); the role comes from the membership table. - // `AuthError::Display` is the single source of these user-facing - // messages, so they cannot drift from the error variants. - let tenant_name = match token_data.tenant_name(&state.config, req.headers()) { - Ok(name) => name, - Err(e) => { - error!("Tenant resolution failed: {e}"); - return Err((create_authz_json_error(&e.to_string()), req)); - } - }; + // Non-owner: the membership table is the authorization authority; + // the tenancy strategy only provisions (see `provision_login`). + let selector = req + .headers() + .get(TENANT_HEADER) + .and_then(|h| h.to_str().ok()) + .filter(|s| !s.is_empty()) + .map(str::to_string); + + if let Err(e) = provision_login( + &state, + &token_data, + selector.as_deref(), + &provider, + &subject, + email.as_deref(), + ) + .await + { + error!("Could not resolve login, with error {e}"); + return Err(( + create_authz_json_error(&format!("Database error while resolving login: {e}")), + req, + )); + } - let resolved = { + let memberships = { let db = state.db.lock().await; - db.resolve_login( - Uuid::now_v7(), - Uuid::now_v7(), - tenant_name, - provider, - subject, - email, - state.config.default_role, - state.config.first_user_role, - ) - .await - }; - match resolved { - Ok((tenant_id, _user_id, role)) => { - AuthenticatedPrincipal { - acting_tenant: tenant_id, - role, - label, + match db.list_user_memberships(&provider, &subject).await { + Ok(memberships) => memberships, + Err(e) => { + return Err((crate::error::ManagerError::from(e).into(), req)); } - .install(&req); - Ok(req) - } - Err(e) => { - error!("Could not resolve login, with error {}", e); - Err(( - create_authz_json_error(&format!( - "Database error while resolving login: {e}" - )), - req, - )) } + }; + + let (acting_tenant, role) = + match select_acting_tenant(&state, selector.as_deref(), &memberships).await { + Ok(pair) => pair, + Err(DBError::AmbiguousTenantMembership | DBError::NoTenantMemberships) + if is_session_request(&req) => + { + // The session endpoint answers a login with no + // resolvable acting tenant (none, or several without a + // selector), so the console can drive its tenant + // picker. The sentinel principal reaches only this + // endpoint, which never reads its acting tenant when + // the marker is present. + req.extensions_mut().insert(UnresolvedActingTenant); + AuthenticatedPrincipal { + acting_tenant: DEFAULT_TENANT_ID, + role: Role::Read, + label, + } + .install(&req); + return Ok(req); + } + Err(e) => return Err((crate::error::ManagerError::from(e).into(), req)), + }; + AuthenticatedPrincipal { + acting_tenant, + role, + label, } + .install(&req); + Ok(req) } Err(error) => { let descr = match error { @@ -752,12 +1021,23 @@ trait OidcClaimExt { impl OidcClaimExt for TokenData { fn authorized_tenants(&self) -> Option> { - // Priority: tenants array > single tenant claim - if let Some(ref tenants) = self.claims.tenants { - Some(tenants.clone()) + // Priority: tenants array > single tenant claim. + // + // Empty and whitespace-only entries are dropped: IdP claim templates + // emit "" when a group mapping evaluates empty, and that must mean + // "no claim" (fall through to derivation), not a shared tenant + // literally named the empty string. + let listed = if let Some(ref tenants) = self.claims.tenants { + tenants.clone() } else { - self.claims.tenant.as_ref().map(|t| vec![t.clone()]) - } + self.claims.tenant.iter().cloned().collect() + }; + let non_empty: Vec = listed + .into_iter() + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + (!non_empty.is_empty()).then_some(non_empty) } fn tenant_name( @@ -768,10 +1048,10 @@ impl OidcClaimExt for TokenData { let issuer = &self.claims.iss; let sub = &self.claims.sub; - // Check if we have explicit tenant authorization in the claim - if let Some(authorized) = self.authorized_tenants() - && !authorized.is_empty() - { + // Check if we have explicit tenant authorization in the claim. + // `authorized_tenants` never returns an empty list: a claim with no + // usable entries is `None` and falls through to derivation. + if let Some(authorized) = self.authorized_tenants() { let selected = headers .get(TENANT_HEADER) .and_then(|h| h.to_str().ok()) @@ -789,22 +1069,9 @@ impl OidcClaimExt for TokenData { None => Err(AuthError::MissingTenantHeader), }; } - // Empty array falls through to fallback logic // Fallback when the token claims no tenant at all: derive one. - // Priority: issuer-domain > sub (if enabled) - let derived = config - .issuer_tenant - .then(|| extract_tenant_from_issuer(issuer)) - .flatten() - .or_else(|| { - config.individual_tenant.then(|| { - debug!("Using sub claim for tenant resolution: {}", sub); - sub.clone() - }) - }); - - let Some(derived) = derived else { + let Some(derived) = derived_tenant_name(config, issuer, sub) else { return Err(AuthError::NoTenantFound); }; @@ -828,6 +1095,26 @@ impl OidcClaimExt for TokenData { } } +/// The tenant name the strategy derives for a token that claims none. +/// Priority: issuer domain (if enabled) over the `sub` claim (if enabled); +/// `None` when both derivations are off. +fn derived_tenant_name( + config: &crate::config::ApiServerConfig, + issuer: &str, + sub: &str, +) -> Option { + config + .issuer_tenant + .then(|| extract_tenant_from_issuer(issuer)) + .flatten() + .or_else(|| { + config.individual_tenant.then(|| { + debug!("Using sub claim for tenant resolution: {}", sub); + sub.to_string() + }) + }) +} + /// Validates that the user belongs to at least one required group (for /// providers that support groups). Passes when no groups are configured. fn validate_groups_authorization( @@ -923,7 +1210,7 @@ async fn resolve_issuer_jwk( cache.mark_refreshed(issuer); } - let keys = fetch_issuer_jwks(issuer, destination, &state.oidc_root_certs).await?; + let keys = fetch_issuer_jwks(issuer, destination, &state.oidc_clients).await?; let mut cache = state.issuer_jwk_cache.lock().await; cache.insert(issuer, keys); cache @@ -985,9 +1272,12 @@ pub(crate) async fn generic_oidc_auth_config( // Use OIDC discovery to fetch jwks_uri. The operator set this issuer, so it // may name a provider on the deployment's own network. - let jwk_uri = - fetch_jwks_uri_from_discovery(&iss, OidcDestination::OperatorConfigured, extra_roots) - .await?; + let jwk_uri = fetch_jwks_uri_from_discovery( + &iss, + OidcDestination::OperatorConfigured, + &OidcClients::new(extra_roots)?, + ) + .await?; validation.set_issuer(&[&iss]); // Use configurable audience claim from API server configuration @@ -1063,7 +1353,14 @@ struct OidcClaim { /// Whether the identity provider verified the email address. Owner matching /// on `email` requires this to be `true`: an unverified, user-settable email /// must never confer the platform-wide `owner` role. - email_verified: Option, + #[serde(default, deserialize_with = "deserialize_bool_or_string")] + email_verified: bool, + + /// When the user last authenticated, in Unix time. Standard OIDC (Core 1.0 + /// §2), and unchanged by token refresh, so a newer value marks a genuine + /// new login rather than a renewed token. Drives how often Feldera asks the + /// provider for the user's profile (see [`crate::oidc::userinfo`]). + auth_time: Option, /// Tenant identifier for single-tenant deployments /// TODO: Deprecated, remove when no one no longer uses it @@ -1173,7 +1470,7 @@ async fn decode_oidc_token( let state = req.app_data::>().unwrap(); let cache = &mut state.jwk_cache.lock().await; let jwk = cache - .get(&kid, &configuration.provider, &state.oidc_root_certs) + .get(&kid, &configuration.provider, &state.oidc_clients) .await?; let token_data = decode::(token, &jwk, &configuration.validation); @@ -1326,7 +1623,7 @@ impl JwkCache { &mut self, key: &String, provider: &AuthProvider, - extra_roots: &[Certificate], + clients: &OidcClients, ) -> Result { let cache = &mut self.cache; let val = &cache.cache_get(key); @@ -1334,7 +1631,7 @@ impl JwkCache { Some(dk) => Ok((*dk).clone()), None => { // TODO: Introduce a minimum delay between refreshes - let fetched = fetch_jwk_keys(provider, extra_roots).await; + let fetched = fetch_jwk_keys(provider, clients).await; match fetched { Ok(map) => { for (key_id, decoding_key) in map { @@ -1355,14 +1652,12 @@ impl JwkCache { async fn fetch_jwk_keys( provider: &AuthProvider, - extra_roots: &[Certificate], + clients: &OidcClients, ) -> Result, AuthError> { match &provider { - AuthProvider::AwsCognito(provider) => { - fetch_jwk_oidc_keys(&provider.jwk_uri, extra_roots).await - } + AuthProvider::AwsCognito(provider) => fetch_jwk_oidc_keys(&provider.jwk_uri, clients).await, AuthProvider::GenericOidc(provider) => { - fetch_jwk_oidc_keys(&provider.jwk_uri, extra_roots).await + fetch_jwk_oidc_keys(&provider.jwk_uri, clients).await } } } @@ -1378,10 +1673,9 @@ async fn fetch_jwk_keys( // outside the public web PKI, including this deployment's own CA. async fn fetch_jwk_oidc_keys( url: &str, - extra_roots: &[Certificate], + clients: &OidcClients, ) -> Result, AuthError> { - let client = oidc_http_client(OidcDestination::OperatorConfigured, extra_roots) - .map_err(|e| AuthError::JwkShape(format!("OIDC client build: {e}")))?; + let client = clients.for_destination(OidcDestination::OperatorConfigured); let response = client.get(url).send().await.map_err(|e| { debug!("JWK fetch request failed: {:?}", e); @@ -1500,6 +1794,7 @@ mod test { use super::{AuthError, AuthenticatedPrincipal}; use crate::db::types::role::{MintableKeyRole, Role}; + use crate::oidc::fetch::OidcClients; use crate::{ api::main::ServerState, auth::{self, AuthConfiguration, AuthProvider, OidcClaim}, @@ -1532,6 +1827,31 @@ mod test { validation } + fn default_manager_test_config() -> ApiServerConfig { + ApiServerConfig { + auth_provider: crate::config::AuthProviderType::AwsCognito, + dev_mode: false, + dump_openapi: false, + allowed_origins: None, + demos_dir: vec![], + telemetry: "".to_owned(), + conceptualhq: "".to_owned(), + product_fruits: "".to_owned(), + support_data_collection_frequency: 15, + support_data_retention: 3, + authorized_groups: vec![], + individual_tenant: true, + issuer_tenant: false, + auth_audience: "feldera-api".to_string(), + owners: vec![], + owner_trusts: crate::config::OwnerTrusts::default(), + allow_internal_tenant_trust_issuers: false, + default_role: Role::Read, + first_user_role: Role::Admin, + provision_on_login: true, + } + } + fn default_claim() -> OidcClaim { OidcClaim { aud: None, @@ -1545,7 +1865,8 @@ mod test { token_use: Some("access".to_owned()), username: Some("some-user".to_owned()), email: None, - email_verified: None, + email_verified: false, + auth_time: None, tenant: None, tenants: None, groups: None, @@ -1553,6 +1874,37 @@ mod test { } } + /// Empty and whitespace claim entries mean "no claim": they fall through + /// to derivation instead of naming a tenant literally called "". + #[tokio::test] + async fn empty_claim_entries_are_ignored() { + use super::OidcClaimExt; + use jsonwebtoken::TokenData; + + let config = default_manager_test_config(); + let headers = actix_web::http::header::HeaderMap::new(); + let name_for = |tenant: Option<&str>, tenants: Option>| { + let mut claim = default_claim(); + claim.tenant = tenant.map(str::to_string); + claim.tenants = tenants.map(|ts| ts.into_iter().map(str::to_string).collect()); + TokenData { + header: Header::new(Algorithm::RS256), + claims: claim, + } + .tenant_name(&config, &headers) + }; + + // individual_tenant is on, so a token with no usable claim derives + // its personal tenant from the sub. + assert_eq!(name_for(None, None).unwrap(), "some-sub"); + assert_eq!(name_for(Some(""), None).unwrap(), "some-sub"); + assert_eq!(name_for(None, Some(vec![""])).unwrap(), "some-sub"); + assert_eq!(name_for(None, Some(vec![" ", ""])).unwrap(), "some-sub"); + // Usable entries survive, trimmed, with empties dropped around them. + assert_eq!(name_for(None, Some(vec![" acme "])).unwrap(), "acme"); + assert_eq!(name_for(None, Some(vec!["", "acme"])).unwrap(), "acme"); + } + async fn run_test( req: actix_http::Request, decoding_key: Option, @@ -1600,27 +1952,7 @@ mod test { disable_cluster_monitor_resources: false, }; - let manager_config = ApiServerConfig { - auth_provider: crate::config::AuthProviderType::AwsCognito, - dev_mode: false, - dump_openapi: false, - allowed_origins: None, - demos_dir: vec![], - telemetry: "".to_owned(), - conceptualhq: "".to_owned(), - product_fruits: "".to_owned(), - support_data_collection_frequency: 15, - support_data_retention: 3, - authorized_groups: vec![], - individual_tenant: true, - issuer_tenant: false, - auth_audience: "feldera-api".to_string(), - owners: vec![], - owner_trusts: crate::config::OwnerTrusts::default(), - allow_internal_tenant_trust_issuers: false, - default_role: Role::Read, - first_user_role: Role::Admin, - }; + let manager_config = default_manager_test_config(); let (conn, _temp) = crate::db::test::setup_pg().await; if let Some(api_key) = api_key { @@ -1688,7 +2020,7 @@ mod test { async fn invalid_url() { ensure_default_crypto_provider(); let url = "http://localhost/doesnotexist".to_owned(); - let res = fetch_jwk_oidc_keys(&url, &[]).await; + let res = fetch_jwk_oidc_keys(&url, &OidcClients::new(&[]).unwrap()).await; assert!(matches!(res.err().unwrap(), AuthError::JwkFetch(_))); } @@ -1717,6 +2049,30 @@ mod test { assert!(!is_configured_owner(&with_blank, "iss", "", None)); } + /// A provider that spells `email_verified` as a string still confers owner + /// on an `owners` entry naming that email, and `auth_time` reaches the + /// profile refresh. + #[tokio::test] + async fn a_string_email_verified_still_counts_as_verified() { + let parse = |json: &str| serde_json::from_str::(json).unwrap(); + let base = r#""exp":1,"iat":1,"iss":"i","sub":"s""#; + + let cognito = parse(&format!(r#"{{{base},"email_verified":"true"}}"#)); + assert!(cognito.email_verified); + assert!(parse(&format!(r#"{{{base},"email_verified":true}}"#)).email_verified); + assert!(!parse(&format!(r#"{{{base},"email_verified":"false"}}"#)).email_verified); + // Absent, or a shape neither spelling covers, reads as unverified + // rather than failing the whole token. + assert!(!parse(&format!("{{{base}}}")).email_verified); + assert!(!parse(&format!(r#"{{{base},"email_verified":"maybe"}}"#)).email_verified); + + assert_eq!( + parse(&format!(r#"{{{base},"auth_time":1737000000}}"#)).auth_time, + Some(1737000000) + ); + assert_eq!(parse(&format!("{{{base}}}")).auth_time, None); + } + #[tokio::test] async fn valid_token() { let claim = default_claim(); diff --git a/crates/pipeline-manager/src/common_error.rs b/crates/pipeline-manager/src/common_error.rs index 26efb841836..788a7b575f8 100644 --- a/crates/pipeline-manager/src/common_error.rs +++ b/crates/pipeline-manager/src/common_error.rs @@ -1,6 +1,4 @@ -use actix_web::{ - HttpResponse, HttpResponseBuilder, ResponseError, body::BoxBody, http::StatusCode, -}; +use actix_web::{HttpResponse, ResponseError, body::BoxBody, http::StatusCode}; use feldera_types::error::{DetailedError, ErrorResponse}; use serde::Serialize; use serde::{Serializer, ser::SerializeStruct}; @@ -181,6 +179,6 @@ impl ResponseError for CommonError { } fn error_response(&self) -> HttpResponse { - HttpResponseBuilder::new(self.status_code()).json(ErrorResponse::from_error(self)) + crate::error::json_error_response(self) } } diff --git a/crates/pipeline-manager/src/compiler/error.rs b/crates/pipeline-manager/src/compiler/error.rs index da88aeedc4f..8ad146a1fcb 100644 --- a/crates/pipeline-manager/src/compiler/error.rs +++ b/crates/pipeline-manager/src/compiler/error.rs @@ -1,6 +1,4 @@ -use actix_web::{ - HttpResponse, HttpResponseBuilder, ResponseError, body::BoxBody, http::StatusCode, -}; +use actix_web::{HttpResponse, ResponseError, body::BoxBody, http::StatusCode}; use feldera_types::error::{DetailedError, ErrorResponse}; use serde::Serialize; use std::{borrow::Cow, error::Error as StdError, fmt, fmt::Display}; @@ -52,6 +50,6 @@ impl ResponseError for CompilerError { } fn error_response(&self) -> HttpResponse { - HttpResponseBuilder::new(self.status_code()).json(ErrorResponse::from_error(self)) + crate::error::json_error_response(self) } } diff --git a/crates/pipeline-manager/src/config.rs b/crates/pipeline-manager/src/config.rs index 5d7f1fef9ee..d9facf2f7d6 100644 --- a/crates/pipeline-manager/src/config.rs +++ b/crates/pipeline-manager/src/config.rs @@ -110,6 +110,11 @@ fn default_individual_tenant() -> bool { true } +/// Default value for provision_on_login flag. +fn default_provision_on_login() -> bool { + true +} + /// Default audience claim value for OIDC authentication. fn default_auth_audience() -> String { "feldera-api".to_string() @@ -1060,6 +1065,20 @@ pub struct ApiServerConfig { #[serde(default = "default_first_user_role")] #[arg(long, default_value = "admin", value_parser = parse_first_user_role, env = "FELDERA_AUTH_FIRST_USER_ROLE")] pub first_user_role: Role, + + /// Provision tenants and memberships at login according to the tenancy + /// strategy: the token's `tenants` claim, the issuer tenant, or the + /// per-`sub` personal tenant. Default: `true`. + /// + /// When `false`, a login creates no tenant and enrolls no one. Access + /// comes only from membership rows granted through the RBAC endpoints, + /// a user with no membership is denied, and `--default-role` and + /// `--first-user-role` do not apply. Requires a configured `--owners` or + /// `--owner-trusts`, because otherwise no principal could ever grant + /// access. + #[serde(default = "default_provision_on_login")] + #[arg(long, action = clap::ArgAction::Set, default_value_t = true, env = "FELDERA_AUTH_PROVISION_ON_LOGIN")] + pub provision_on_login: bool, } /// A trust relationship granting the platform-wide `owner` role, declared at @@ -1135,6 +1154,27 @@ impl FromStr for OwnerTrusts { } impl ApiServerConfig { + /// Refuse a configuration that could never mint a membership: with + /// provisioning off and no configured owner, every login is denied and no + /// principal could ever create a tenant or grant access. A startup error + /// beats a silently bricked installation. + pub fn validate_authorization(&self) -> Result<(), String> { + if self.auth_provider != AuthProviderType::None + && !self.provision_on_login + && self.owners.is_empty() + && self.owner_trusts.0.is_empty() + { + return Err( + "provision-on-login is disabled but neither --owners nor --owner-trusts is \ + configured. With provisioning off, access comes only from membership rows, and \ + without an owner no principal could ever create one. Configure an owner or \ + re-enable --provision-on-login." + .to_string(), + ); + } + Ok(()) + } + /// Where a trust registered through the API may point. pub(crate) fn tenant_issuer_policy(&self) -> TenantIssuerPolicy { if self.allow_internal_tenant_trust_issuers { @@ -1193,6 +1233,7 @@ impl ApiServerConfig { allow_internal_tenant_trust_issuers: false, default_role: Role::Read, first_user_role: Role::Admin, + provision_on_login: true, } } } @@ -1526,4 +1567,21 @@ mod tests { }; assert!(std::panic::catch_unwind(|| config.https_config()).is_err()); } + + /// Provisioning off without a configured owner is a bricked installation, + /// so startup refuses it. With authentication off the flag is inert. + #[test] + fn provisioning_off_requires_an_owner() { + let mut config = ApiServerConfig::test_config(); + config.auth_provider = AuthProviderType::GenericOidc; + config.provision_on_login = false; + assert!(config.validate_authorization().is_err()); + + config.owners = vec!["ops@acme.test".to_string()]; + assert!(config.validate_authorization().is_ok()); + + config.owners.clear(); + config.auth_provider = AuthProviderType::None; + assert!(config.validate_authorization().is_ok()); + } } diff --git a/crates/pipeline-manager/src/db/error.rs b/crates/pipeline-manager/src/db/error.rs index d3b4eb88792..ea3caa6820d 100644 --- a/crates/pipeline-manager/src/db/error.rs +++ b/crates/pipeline-manager/src/db/error.rs @@ -6,11 +6,10 @@ use crate::db::types::resources_status::{ResourcesDesiredStatus, ResourcesStatus use crate::db::types::role::{InvalidRole, Role}; use crate::db::types::storage::StorageStatus; use crate::db::types::tenant::TenantId; +use crate::db::types::user::InvalidMembershipOrigin; use crate::db::types::utils::ValidationError; use crate::db::types::version::Version; -use actix_web::{ - HttpResponse, HttpResponseBuilder, ResponseError, body::BoxBody, http::StatusCode, -}; +use actix_web::{HttpResponse, ResponseError, body::BoxBody, http::StatusCode}; use deadpool_postgres::PoolError; use feldera_types::error::DetailedError; use feldera_types::error::ErrorResponse; @@ -172,6 +171,15 @@ pub enum DBError { OidcTenantNotTrusted { tenant: String, }, + /// A login whose user belongs to several tenants sent no selector. + AmbiguousTenantMembership, + /// A login named a tenant it holds no membership in, or one that does not + /// exist; one variant for both, so the login path is no existence oracle. + NotATenantMember { + tenant: String, + }, + /// A login whose user belongs to no tenant at all. + NoTenantMemberships, InvalidOidcToken { reason: String, }, @@ -190,6 +198,9 @@ pub enum DBError { InvalidRoleString { value: String, }, + InvalidMembershipOriginString { + value: String, + }, UnknownUser { user_id: String, }, @@ -473,6 +484,12 @@ impl From for DBError { } } +impl From for DBError { + fn from(error: InvalidMembershipOrigin) -> Self { + Self::InvalidMembershipOriginString { value: error.0 } + } +} + impl From for DBError { fn from(error: RefineryError) -> Self { Self::PostgresMigrationError { @@ -697,6 +714,9 @@ impl Display for DBError { DBError::InvalidRoleString { value } => { write!(f, "Invalid role string '{value}' encountered") } + DBError::InvalidMembershipOriginString { value } => { + write!(f, "Invalid membership origin string '{value}' encountered") + } DBError::UnknownUser { user_id } => { write!(f, "Unknown user '{user_id}'") } @@ -725,6 +745,23 @@ impl Display for DBError { DBError::OidcTenantNotTrusted { tenant } => { write!(f, "Token is not trusted in tenant '{tenant}'") } + DBError::AmbiguousTenantMembership => { + write!( + f, + "This user belongs to several tenants; set the Feldera-Tenant \ + header to select one" + ) + } + DBError::NotATenantMember { tenant } => { + write!(f, "Not a member of tenant '{tenant}', or no such tenant") + } + DBError::NoTenantMemberships => { + write!( + f, + "This user has no tenant memberships. A tenant admin grants \ + access through the tenant's member management" + ) + } DBError::InvalidOidcToken { reason } => { write!(f, "Invalid OIDC token: {reason}") } @@ -1054,12 +1091,18 @@ impl DetailedError for DBError { Self::OwnerAdminNotMintableAsApiKey => Cow::from("OwnerAdminNotMintableAsApiKey"), Self::OwnerRoleNotAssignable => Cow::from("OwnerRoleNotAssignable"), Self::InvalidRoleString { .. } => Cow::from("InvalidRoleString"), + Self::InvalidMembershipOriginString { .. } => { + Cow::from("InvalidMembershipOriginString") + } Self::UnknownUser { .. } => Cow::from("UnknownUser"), Self::UnknownOidcTrust { .. } => Cow::from("UnknownOidcTrust"), Self::EmptyOidcTrustField { .. } => Cow::from("EmptyOidcTrustField"), Self::InvalidOidcIssuerUrl { .. } => Cow::from("InvalidOidcIssuerUrl"), Self::AmbiguousOidcTenant => Cow::from("AmbiguousOidcTenant"), Self::OidcTenantNotTrusted { .. } => Cow::from("OidcTenantNotTrusted"), + Self::AmbiguousTenantMembership => Cow::from("AmbiguousTenantMembership"), + Self::NotATenantMember { .. } => Cow::from("NotATenantMember"), + Self::NoTenantMemberships => Cow::from("NoTenantMemberships"), Self::InvalidOidcToken { .. } => Cow::from("InvalidOidcToken"), Self::UnauthorizedOidcToken => Cow::from("UnauthorizedOidcToken"), Self::UnknownPipeline { .. } => Cow::from("UnknownPipeline"), @@ -1182,12 +1225,16 @@ impl ResponseError for DBError { Self::OwnerAdminNotMintableAsApiKey => StatusCode::FORBIDDEN, Self::OwnerRoleNotAssignable => StatusCode::FORBIDDEN, Self::InvalidRoleString { .. } => StatusCode::INTERNAL_SERVER_ERROR, + Self::InvalidMembershipOriginString { .. } => StatusCode::INTERNAL_SERVER_ERROR, Self::UnknownUser { .. } => StatusCode::NOT_FOUND, Self::UnknownOidcTrust { .. } => StatusCode::NOT_FOUND, Self::EmptyOidcTrustField { .. } => StatusCode::BAD_REQUEST, Self::InvalidOidcIssuerUrl { .. } => StatusCode::BAD_REQUEST, Self::AmbiguousOidcTenant => StatusCode::BAD_REQUEST, Self::OidcTenantNotTrusted { .. } => StatusCode::FORBIDDEN, + Self::AmbiguousTenantMembership => StatusCode::BAD_REQUEST, + Self::NotATenantMember { .. } => StatusCode::FORBIDDEN, + Self::NoTenantMemberships => StatusCode::FORBIDDEN, Self::InvalidOidcToken { .. } => StatusCode::UNAUTHORIZED, Self::UnauthorizedOidcToken => StatusCode::UNAUTHORIZED, Self::UnknownPipeline { .. } => StatusCode::NOT_FOUND, @@ -1237,7 +1284,7 @@ impl ResponseError for DBError { } fn error_response(&self) -> HttpResponse { - HttpResponseBuilder::new(self.status_code()).json(ErrorResponse::from_error(self)) + crate::error::json_error_response(self) } } diff --git a/crates/pipeline-manager/src/db/operations/tenant.rs b/crates/pipeline-manager/src/db/operations/tenant.rs index ae59dca7d5b..2fcde8ccf8d 100644 --- a/crates/pipeline-manager/src/db/operations/tenant.rs +++ b/crates/pipeline-manager/src/db/operations/tenant.rs @@ -18,6 +18,45 @@ pub async fn get_or_create_tenant_id( .0) } +/// Retrieves the tenant with this name, creating it with `new_id` if it does +/// not exist yet. The second component is `true` if this call created the +/// tenant, and `false` if it already existed. +/// +/// Atomic get-or-create: a single INSERT ... ON CONFLICT DO NOTHING avoids +/// the SELECT-then-INSERT race where two concurrent creations of a fresh +/// name both miss the SELECT, then one INSERT wins and the other fails with a +/// unique violation. `inserted` (1 vs 0 rows affected) tells us whether THIS +/// call created the tenant. The SELECT after it misses only if a concurrent +/// delete commits in between, which surfaces as an error, not a wrong result. +pub async fn get_or_create_tenant( + txn: &Transaction<'_>, + new_id: Uuid, + name: &str, + provider: &str, +) -> Result<(TenantInfo, bool), DBError> { + let stmt_insert = txn + .prepare_cached( + "INSERT INTO tenant (id, tenant, initial_provider) VALUES ($1, $2, $3) \ + ON CONFLICT (tenant) DO NOTHING", + ) + .await?; + let inserted = txn + .execute(&stmt_insert, &[&new_id, &name, &provider]) + .await?; + let stmt_select = txn + .prepare_cached("SELECT id, tenant, initial_provider FROM tenant WHERE tenant = $1") + .await?; + let row = txn.query_one(&stmt_select, &[&name]).await?; + Ok(( + TenantInfo { + id: TenantId(row.get(0)), + name: row.get(1), + initial_provider: row.get(2), + }, + inserted == 1, + )) +} + /// Ensures the default tenant exists, which every start does. /// /// Keyed by id rather than by name: the default tenant may have been renamed @@ -43,51 +82,15 @@ pub async fn ensure_default_tenant( /// As [`get_or_create_tenant_id`]. The second component of the returned value /// is `true` if this call created the tenant, and `false` if it already existed. +/// The created flag decides the first-member grant in `resolve_login`. pub async fn get_or_create_tenant_id_created( txn: &Transaction<'_>, new_id: Uuid, name: String, provider: String, ) -> Result<(TenantId, bool), DBError> { - // Atomic get-or-create: a single INSERT ... ON CONFLICT DO NOTHING avoids - // the SELECT-then-INSERT race where two concurrent first-logins to a fresh - // name both miss the SELECT, then one INSERT wins and the other fails with a - // unique violation. `inserted` (1 vs 0 rows affected) tells us whether THIS - // call created the tenant, which decides the first-member grant in - // `resolve_login`. A subsequent SELECT always finds the row. - // - // The name alone identifies the tenant. `provider` is recorded as the issuer - // it was first seen under, and deliberately not matched on: were it part of - // the key, changing the configured issuer would miss here and fork a second - // tenant of the same name, stranding the pipelines on the first. - let stmt_insert = txn - .prepare_cached( - "INSERT INTO tenant (id, tenant, initial_provider) VALUES ($1, $2, $3) \ - ON CONFLICT (tenant) DO NOTHING", - ) - .await?; - let inserted = txn - .execute(&stmt_insert, &[&new_id, &name, &provider]) - .await?; - let stmt_select = txn - .prepare_cached("SELECT id FROM tenant WHERE tenant = $1") - .await?; - let row = txn.query_one(&stmt_select, &[&name]).await?; - Ok((TenantId(row.get(0)), inserted == 1)) -} - -/// Strict lookup of a tenant by name, used to resolve a `Feldera-Tenant` header. -/// Never creates a tenant; a miss is an error. The name is unique, so at most -/// one tenant can match. -pub async fn get_tenant_id_by_name(txn: &Transaction<'_>, name: &str) -> Result { - let stmt = txn - .prepare_cached("SELECT id FROM tenant WHERE tenant = $1") - .await?; - let row = txn.query_opt(&stmt, &[&name]).await?; - row.map(|row| TenantId(row.get(0))) - .ok_or(DBError::UnknownTenantName { - name: name.to_string(), - }) + let (tenant, created) = get_or_create_tenant(txn, new_id, &name, &provider).await?; + Ok((tenant.id, created)) } /// Strict resolution of a `Feldera-Tenant` selector, used wherever a principal @@ -101,37 +104,47 @@ pub async fn resolve_tenant_selector( txn: &Transaction<'_>, selector: &str, ) -> Result { - if let Ok(uuid) = Uuid::parse_str(selector) { - let stmt = txn - .prepare_cached("SELECT id FROM tenant WHERE id = $1") - .await?; - let row = txn.query_opt(&stmt, &[&uuid]).await?; - return row - .map(|r| TenantId(r.get(0))) - .ok_or_else(|| DBError::UnknownTenantName { - name: selector.to_string(), - }); - } - get_tenant_id_by_name(txn, selector).await + Ok(get_tenant(txn, selector).await?.id) } -/// Create a tenant, failing with a conflict if the name is already taken. -/// Distinct from the get-or-create login path: the owner-only explicit -/// create endpoint should report a duplicate rather than silently returning the -/// existing tenant. -pub async fn create_tenant( +/// Looks a tenant up by name alone, `None` on miss. Unlike [`get_tenant`], +/// a name that happens to parse as a UUID is still treated as a name. +pub async fn find_tenant_id_by_name( txn: &Transaction<'_>, - id: Uuid, name: &str, - provider: &str, -) -> Result { +) -> Result, DBError> { let stmt = txn - .prepare_cached("INSERT INTO tenant (id, tenant, initial_provider) VALUES ($1, $2, $3)") + .prepare_cached("SELECT id FROM tenant WHERE tenant = $1") .await?; - txn.execute(&stmt, &[&id, &name, &provider]) - .await - .map_err(maybe_unique_violation)?; - Ok(TenantId(id)) + Ok(txn + .query_opt(&stmt, &[&name]) + .await? + .map(|row| TenantId(row.get(0)))) +} + +/// Retrieves a single tenant by selector, as [`resolve_tenant_selector`]: +/// a selector that parses as a UUID is looked up by tenant id, otherwise by +/// name. Never creates a tenant; errors with `UnknownTenantName` on miss. +pub async fn get_tenant(txn: &Transaction<'_>, selector: &str) -> Result { + let row = if let Ok(uuid) = Uuid::parse_str(selector) { + let stmt = txn + .prepare_cached("SELECT id, tenant, initial_provider FROM tenant WHERE id = $1") + .await?; + txn.query_opt(&stmt, &[&uuid]).await? + } else { + let stmt = txn + .prepare_cached("SELECT id, tenant, initial_provider FROM tenant WHERE tenant = $1") + .await?; + txn.query_opt(&stmt, &[&selector]).await? + }; + row.map(|row| TenantInfo { + id: TenantId(row.get(0)), + name: row.get(1), + initial_provider: row.get(2), + }) + .ok_or(DBError::UnknownTenantName { + name: selector.to_string(), + }) } /// Renames a tenant, failing with a conflict if the name is already taken. diff --git a/crates/pipeline-manager/src/db/operations/user.rs b/crates/pipeline-manager/src/db/operations/user.rs index ec868e5fdc6..58f76bec7a6 100644 --- a/crates/pipeline-manager/src/db/operations/user.rs +++ b/crates/pipeline-manager/src/db/operations/user.rs @@ -7,7 +7,7 @@ use crate::db::operations::utils::{ }; use crate::db::types::role::Role; use crate::db::types::tenant::TenantId; -use crate::db::types::user::{TenantMember, UserId}; +use crate::db::types::user::{MembershipOrigin, TenantMember, UserId, UserMembership, UserProfile}; use deadpool_postgres::Transaction; use std::str::FromStr; use uuid::Uuid; @@ -28,8 +28,16 @@ pub async fn get_or_create_user( // COALESCE keeps a previously stored email when a later token omits // the claim (some IdPs drop email on refresh-derived access tokens), // rather than overwriting it with NULL. + // + // An address the provider vouched for outranks both callers here: a + // token claim, and the email an administrator types into + // `preprovision_member`. Neither carries verification, so letting + // either overwrite would strip a verified address of its mark or, + // worse, leave the mark next to a different address. "INSERT INTO app_user (id, provider, subject, email) VALUES ($1, $2, $3, $4) \ - ON CONFLICT (provider, subject) DO UPDATE SET email = COALESCE(EXCLUDED.email, app_user.email) \ + ON CONFLICT (provider, subject) DO UPDATE SET \ + email = CASE WHEN app_user.email_verified THEN app_user.email \ + ELSE COALESCE(EXCLUDED.email, app_user.email) END \ RETURNING id", ) .await?; @@ -41,8 +49,8 @@ pub async fn get_or_create_user( /// Pre-provision a tenant member by identity: create the user record if it does /// not exist yet and set its role. Lets an admin grant access before the user's -/// first login (the grant is dormant until the identity authenticates through -/// the IdP into this tenant). Returns the user id. +/// first login; the membership authorizes on its own as soon as that identity +/// authenticates through the IdP. Returns the user id. pub async fn preprovision_member( txn: &Transaction<'_>, new_user_id: Uuid, @@ -53,7 +61,7 @@ pub async fn preprovision_member( role: Role, ) -> Result { let user_id = get_or_create_user(txn, new_user_id, provider, subject, email).await?; - upsert_member_role(txn, tenant_id, user_id, role).await?; + upsert_member_role(txn, tenant_id, user_id, role, MembershipOrigin::Api).await?; Ok(user_id) } @@ -74,27 +82,35 @@ pub async fn get_member_role( } /// Inserts or updates a membership row. The role must be `<= admin` -/// (`owner` is never stored); the caller enforces the cap. +/// (`owner` is never stored); the caller enforces the cap. `origin` and the +/// creation timestamp record provenance and only apply to a fresh row: a +/// conflict updates the role and keeps how and when the membership was first +/// created. pub async fn upsert_member_role( txn: &Transaction<'_>, tenant_id: TenantId, user_id: UserId, role: Role, + origin: MembershipOrigin, ) -> Result<(), DBError> { let stmt = txn .prepare_cached( // On conflict the membership already exists, so overwrite its role // with `EXCLUDED.role`, PostgreSQL's name for the value this // statement tried to insert. Insert and update in one statement. - "INSERT INTO tenant_membership (tenant_id, user_id, role) VALUES ($1, $2, $3) \ + "INSERT INTO tenant_membership (tenant_id, user_id, role, created_at, origin) \ + VALUES ($1, $2, $3, now(), $4) \ ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role", ) .await?; - txn.execute(&stmt, &[&tenant_id.0, &user_id.0, &role.as_str()]) - .await - .map_err(DBError::from) - .map_err(|e| maybe_tenant_id_foreign_key_constraint_err(e, tenant_id)) - .map_err(|e| maybe_user_id_foreign_key_constraint_err(e, user_id))?; + txn.execute( + &stmt, + &[&tenant_id.0, &user_id.0, &role.as_str(), &origin.as_str()], + ) + .await + .map_err(DBError::from) + .map_err(|e| maybe_tenant_id_foreign_key_constraint_err(e, tenant_id)) + .map_err(|e| maybe_user_id_foreign_key_constraint_err(e, user_id))?; Ok(()) } @@ -130,7 +146,8 @@ pub async fn list_tenant_members( // `provider` breaks the tie: two identities can share a subject // across providers, and both may have no email, which would // otherwise leave the order for those rows unspecified. - "SELECT u.id, u.provider, u.subject, u.email, m.role \ + "SELECT u.id, u.provider, u.subject, u.email, u.email_verified, u.display_name, \ + m.role, m.origin \ FROM tenant_membership m JOIN app_user u ON u.id = m.user_id \ WHERE m.tenant_id = $1 ORDER BY u.email, u.subject, u.provider", ) @@ -143,7 +160,147 @@ pub async fn list_tenant_members( provider: row.get(1), subject: row.get(2), email: row.get(3), - role: Role::from_str(&row.get::<_, String>(4))?, + email_verified: row.get(4), + display_name: row.get(5), + role: Role::from_str(&row.get::<_, String>(6))?, + origin: row + .get::<_, Option<&str>>(7) + .map(MembershipOrigin::from_str) + .transpose()?, + }); + } + Ok(result) +} + +/// Claim the right to refresh this identity's profile from the provider, and +/// report whether this caller won it. Creates the identity's row when the user +/// has none yet, which is the case for a platform owner: an owner's role comes +/// from configuration, so nothing else on the login path records one. +/// +/// A refresh is due when none has run, when the last one has aged past +/// `ttl_seconds`, or when `auth_time` says the user authenticated more recently +/// than the last one covered. Deciding and recording in one statement is what +/// makes the claim exclusive: PostgreSQL locks the row for the update, so +/// exactly one caller sees a row returned however many api-servers ask at once. +pub async fn claim_profile_refresh( + txn: &Transaction<'_>, + new_id: Uuid, + provider: &str, + subject: &str, + auth_time: Option, + ttl_seconds: i64, +) -> Result { + let stmt = txn + .prepare_cached( + "INSERT INTO app_user (id, provider, subject, profile_refreshed_at, profile_auth_time) \ + VALUES ($1, $2, $3, now(), $4) \ + ON CONFLICT (provider, subject) DO UPDATE \ + SET profile_refreshed_at = now(), profile_auth_time = EXCLUDED.profile_auth_time \ + WHERE app_user.profile_refreshed_at IS NULL \ + OR app_user.profile_refreshed_at < now() - ($5::bigint * INTERVAL '1 second') \ + OR (EXCLUDED.profile_auth_time IS NOT NULL \ + AND (app_user.profile_auth_time IS NULL \ + OR EXCLUDED.profile_auth_time > app_user.profile_auth_time)) \ + RETURNING id", + ) + .await?; + let claimed = txn + .query_opt( + &stmt, + &[&new_id, &provider, &subject, &auth_time, &ttl_seconds], + ) + .await?; + Ok(claimed.is_some()) +} + +/// Store what the provider reports about an identity. Absent fields leave the +/// stored ones alone, so a token scoped too narrowly to see the email does not +/// erase one an earlier, wider login recorded. +pub async fn store_user_profile( + txn: &Transaction<'_>, + provider: &str, + subject: &str, + profile: &UserProfile, +) -> Result<(), DBError> { + let stmt = txn + .prepare_cached( + // Whether an address is verified is a statement about that address, + // so the two move together: keeping the stored email means keeping + // the verdict that came with it. + "UPDATE app_user SET \ + email = COALESCE($3, email), \ + email_verified = CASE WHEN $3 IS NULL THEN email_verified ELSE $4 END, \ + display_name = COALESCE($5, display_name) \ + WHERE provider = $1 AND subject = $2", + ) + .await?; + txn.execute( + &stmt, + &[ + &provider, + &subject, + &profile.email, + &profile.email_verified, + &profile.display_name, + ], + ) + .await?; + Ok(()) +} + +/// Enrolls a user into the listed tenants where the tenant already exists and +/// the user is not yet a member. Never creates a tenant and never changes an +/// existing membership: a passively listed claim entry must not mint a tenant +/// with the logger-in as its admin, nor overwrite a role an admin set. +#[allow(clippy::too_many_arguments)] +pub async fn enroll_in_existing_tenants( + txn: &Transaction<'_>, + new_user_id: Uuid, + provider: &str, + subject: &str, + email: Option<&str>, + names: &[String], + role: Role, + origin: MembershipOrigin, +) -> Result<(), DBError> { + let user_id = get_or_create_user(txn, new_user_id, provider, subject, email).await?; + let stmt = txn + .prepare_cached( + "INSERT INTO tenant_membership (tenant_id, user_id, role, created_at, origin) \ + SELECT t.id, $2, $3, now(), $4 FROM tenant t WHERE t.tenant = $1 \ + ON CONFLICT (tenant_id, user_id) DO NOTHING", + ) + .await?; + for name in names { + txn.execute(&stmt, &[name, &user_id.0, &role.as_str(), &origin.as_str()]) + .await?; + } + Ok(()) +} + +/// Lists the tenants a user may act in, joined with each tenant's name and +/// the user's role there. The authorization source of truth at login. +pub async fn list_user_memberships( + txn: &Transaction<'_>, + provider: &str, + subject: &str, +) -> Result, DBError> { + let stmt = txn + .prepare_cached( + "SELECT m.tenant_id, t.tenant, m.role \ + FROM tenant_membership m \ + JOIN app_user u ON u.id = m.user_id \ + JOIN tenant t ON t.id = m.tenant_id \ + WHERE u.provider = $1 AND u.subject = $2 ORDER BY t.tenant", + ) + .await?; + let rows = txn.query(&stmt, &[&provider, &subject]).await?; + let mut result = Vec::with_capacity(rows.len()); + for row in rows { + result.push(UserMembership { + tenant_id: TenantId(row.get(0)), + name: row.get(1), + role: Role::from_str(&row.get::<_, String>(2))?, }); } Ok(result) @@ -167,6 +324,7 @@ pub async fn resolve_login( email: Option, default_role: Role, first_user_role: Role, + origin: MembershipOrigin, // How the token named the tenant: claim or derived. ) -> Result<(TenantId, UserId, Role), DBError> { let (tenant_id, created) = get_or_create_tenant_id_created(txn, new_tenant_id, tenant_name, provider.clone()).await?; @@ -181,9 +339,42 @@ pub async fn resolve_login( } else { default_role }; - upsert_member_role(txn, tenant_id, user_id, role).await?; - role + // Insert-if-absent: an admin grant committed between the read + // above and this write must win, not be overwritten. + if insert_membership_if_absent(txn, tenant_id, user_id, role, origin).await? { + role + } else { + get_member_role(txn, tenant_id, user_id) + .await? + .unwrap_or(role) + } } }; Ok((tenant_id, user_id, role)) } + +/// Inserts a membership only if none exists; `true` when this call created it. +async fn insert_membership_if_absent( + txn: &Transaction<'_>, + tenant_id: TenantId, + user_id: UserId, + role: Role, + origin: MembershipOrigin, +) -> Result { + let stmt = txn + .prepare_cached( + "INSERT INTO tenant_membership (tenant_id, user_id, role, created_at, origin) \ + VALUES ($1, $2, $3, now(), $4) ON CONFLICT (tenant_id, user_id) DO NOTHING", + ) + .await?; + let inserted = txn + .execute( + &stmt, + &[&tenant_id.0, &user_id.0, &role.as_str(), &origin.as_str()], + ) + .await + .map_err(DBError::from) + .map_err(|e| maybe_tenant_id_foreign_key_constraint_err(e, tenant_id)) + .map_err(|e| maybe_user_id_foreign_key_constraint_err(e, user_id))?; + Ok(inserted == 1) +} diff --git a/crates/pipeline-manager/src/db/storage.rs b/crates/pipeline-manager/src/db/storage.rs index 6269f369c48..ffc9222b2e8 100644 --- a/crates/pipeline-manager/src/db/storage.rs +++ b/crates/pipeline-manager/src/db/storage.rs @@ -14,7 +14,9 @@ use crate::db::types::pipeline::{ use crate::db::types::program::{RustCompilationInfo, SqlCompilationInfo}; use crate::db::types::role::{MintableKeyRole, Role}; use crate::db::types::tenant::TenantId; -use crate::db::types::user::{TenantInfo, TenantMember, UserId}; +use crate::db::types::user::{ + MembershipOrigin, TenantInfo, TenantMember, UserId, UserMembership, UserProfile, +}; use crate::db::types::version::Version; use crate::oidc::destination::TenantIssuerPolicy; use async_trait::async_trait; @@ -101,14 +103,25 @@ pub(crate) trait Storage { /// [`crate::db::operations::tenant::resolve_tenant_selector`]. async fn resolve_tenant_selector(&self, selector: &str) -> Result; - /// Creates a tenant, failing with `DuplicateName` if the name is already - /// taken. - async fn create_tenant( + /// Retrieves a single tenant by selector (a tenant UUID or name). Errors + /// with `UnknownTenantName` on miss. + /// See [`crate::db::operations::tenant::get_tenant`]. + async fn get_tenant(&self, selector: &str) -> Result; + + /// Looks a tenant up by name alone, `None` on miss; a name that parses as + /// a UUID is still a name. For callers that hold a known tenant name, not + /// a user-supplied selector. + async fn find_tenant_id_by_name(&self, name: &str) -> Result, DBError>; + + /// Retrieves the tenant with this name, creating it if absent. The second + /// component is `true` if this call created the tenant. + /// See [`crate::db::operations::tenant::get_or_create_tenant`]. + async fn get_or_create_tenant( &self, - id: Uuid, + new_id: Uuid, // Used only if the tenant does not yet exist name: &str, provider: &str, - ) -> Result; + ) -> Result<(TenantInfo, bool), DBError>; /// Renames a tenant, failing with `DuplicateName` if the name is already /// taken, unless `displace_existing` lets it take the name from the tenant @@ -145,10 +158,36 @@ pub(crate) trait Storage { email: Option, default_role: Role, first_user_role: Role, + origin: MembershipOrigin, ) -> Result<(TenantId, UserId, Role), DBError>; - /// Ensures a user record exists for an OIDC `(provider, subject)`. - #[allow(dead_code)] // Provided for completeness; logins go through `resolve_login`. + /// Lists the tenants a user may act in, with the user's role in each. + /// See [`crate::db::operations::user::list_user_memberships`]. + async fn list_user_memberships( + &self, + provider: &str, + subject: &str, + ) -> Result, DBError>; + + /// Enrolls a user into the listed tenants where the tenant exists and the + /// user is not yet a member; never creates tenants or changes existing + /// memberships. See + /// [`crate::db::operations::user::enroll_in_existing_tenants`]. + #[allow(clippy::too_many_arguments)] + async fn enroll_in_existing_tenants( + &self, + new_user_id: Uuid, + provider: &str, + subject: &str, + email: Option<&str>, + names: &[String], + role: Role, + origin: MembershipOrigin, + ) -> Result<(), DBError>; + + /// Ensures a user record exists for an OIDC `(provider, subject)` and + /// refreshes its stored email. The login path with provisioning off calls + /// this instead of [`Storage::resolve_login`]. async fn get_or_create_user( &self, new_id: Uuid, @@ -157,15 +196,38 @@ pub(crate) trait Storage { email: Option<&str>, ) -> Result; + /// Claims the right to refresh an identity's profile from its provider, + /// reporting whether this caller won it. See + /// [`crate::db::operations::user::claim_profile_refresh`]. + async fn claim_profile_refresh( + &self, + new_id: Uuid, + provider: &str, + subject: &str, + auth_time: Option, + ttl_seconds: i64, + ) -> Result; + + /// Stores what the identity provider reports about an identity. + /// See [`crate::db::operations::user::store_user_profile`]. + async fn store_user_profile( + &self, + provider: &str, + subject: &str, + profile: &UserProfile, + ) -> Result<(), DBError>; + /// Lists the members of a tenant with their roles. async fn list_tenant_members(&self, tenant_id: TenantId) -> Result, DBError>; - /// Assigns or updates a member's role within a tenant. + /// Assigns or updates a member's role within a tenant. `origin` records + /// provenance on a fresh row; an existing row keeps its recorded origin. async fn upsert_member_role( &self, tenant_id: TenantId, user_id: UserId, role: Role, + origin: MembershipOrigin, ) -> Result<(), DBError>; /// Pre-provisions a tenant member by identity `(provider, subject)`: ensures diff --git a/crates/pipeline-manager/src/db/storage_postgres.rs b/crates/pipeline-manager/src/db/storage_postgres.rs index 39cc6d15a22..f621abbcecc 100644 --- a/crates/pipeline-manager/src/db/storage_postgres.rs +++ b/crates/pipeline-manager/src/db/storage_postgres.rs @@ -24,7 +24,9 @@ use crate::db::types::resources_status::{ResourcesDesiredStatus, ResourcesStatus use crate::db::types::role::{MintableKeyRole, Role}; use crate::db::types::storage::StorageStatus; use crate::db::types::tenant::TenantId; -use crate::db::types::user::{TenantInfo, TenantMember, UserId}; +use crate::db::types::user::{ + MembershipOrigin, TenantInfo, TenantMember, UserId, UserMembership, UserProfile, +}; use crate::db::types::version::Version; use crate::is_supported_runtime; use crate::oidc::destination::TenantIssuerPolicy; @@ -126,15 +128,31 @@ impl Storage for StoragePostgres { Ok(result) } - async fn create_tenant( + async fn get_tenant(&self, selector: &str) -> Result { + let mut client = self.pool.get().await?; + let txn = client.transaction().await?; + let result = operations::tenant::get_tenant(&txn, selector).await?; + txn.commit().await?; + Ok(result) + } + + async fn find_tenant_id_by_name(&self, name: &str) -> Result, DBError> { + let mut client = self.pool.get().await?; + let txn = client.transaction().await?; + let result = operations::tenant::find_tenant_id_by_name(&txn, name).await?; + txn.commit().await?; + Ok(result) + } + + async fn get_or_create_tenant( &self, - id: Uuid, + new_id: Uuid, name: &str, provider: &str, - ) -> Result { + ) -> Result<(TenantInfo, bool), DBError> { let mut client = self.pool.get().await?; let txn = client.transaction().await?; - let result = operations::tenant::create_tenant(&txn, id, name, provider).await?; + let result = operations::tenant::get_or_create_tenant(&txn, new_id, name, provider).await?; txn.commit().await?; Ok(result) } @@ -180,6 +198,7 @@ impl Storage for StoragePostgres { email: Option, default_role: Role, first_user_role: Role, + origin: MembershipOrigin, ) -> Result<(TenantId, UserId, Role), DBError> { let mut client = self.pool.get().await?; let txn = client.transaction().await?; @@ -193,12 +212,52 @@ impl Storage for StoragePostgres { email, default_role, first_user_role, + origin, ) .await?; txn.commit().await?; Ok(result) } + async fn list_user_memberships( + &self, + provider: &str, + subject: &str, + ) -> Result, DBError> { + let mut client = self.pool.get().await?; + let txn = client.transaction().await?; + let result = operations::user::list_user_memberships(&txn, provider, subject).await?; + txn.commit().await?; + Ok(result) + } + + async fn enroll_in_existing_tenants( + &self, + new_user_id: Uuid, + provider: &str, + subject: &str, + email: Option<&str>, + names: &[String], + role: Role, + origin: MembershipOrigin, + ) -> Result<(), DBError> { + let mut client = self.pool.get().await?; + let txn = client.transaction().await?; + operations::user::enroll_in_existing_tenants( + &txn, + new_user_id, + provider, + subject, + email, + names, + role, + origin, + ) + .await?; + txn.commit().await?; + Ok(()) + } + async fn get_or_create_user( &self, new_id: Uuid, @@ -214,6 +273,42 @@ impl Storage for StoragePostgres { Ok(result) } + async fn claim_profile_refresh( + &self, + new_id: Uuid, + provider: &str, + subject: &str, + auth_time: Option, + ttl_seconds: i64, + ) -> Result { + let mut client = self.pool.get().await?; + let txn = client.transaction().await?; + let result = operations::user::claim_profile_refresh( + &txn, + new_id, + provider, + subject, + auth_time, + ttl_seconds, + ) + .await?; + txn.commit().await?; + Ok(result) + } + + async fn store_user_profile( + &self, + provider: &str, + subject: &str, + profile: &UserProfile, + ) -> Result<(), DBError> { + let mut client = self.pool.get().await?; + let txn = client.transaction().await?; + operations::user::store_user_profile(&txn, provider, subject, profile).await?; + txn.commit().await?; + Ok(()) + } + async fn list_tenant_members(&self, tenant_id: TenantId) -> Result, DBError> { let mut client = self.pool.get().await?; let txn = client.transaction().await?; @@ -227,10 +322,11 @@ impl Storage for StoragePostgres { tenant_id: TenantId, user_id: UserId, role: Role, + origin: MembershipOrigin, ) -> Result<(), DBError> { let mut client = self.pool.get().await?; let txn = client.transaction().await?; - operations::user::upsert_member_role(&txn, tenant_id, user_id, role).await?; + operations::user::upsert_member_role(&txn, tenant_id, user_id, role, origin).await?; txn.commit().await?; Ok(()) } diff --git a/crates/pipeline-manager/src/db/test.rs b/crates/pipeline-manager/src/db/test.rs index e3bdecca54d..a5784613320 100644 --- a/crates/pipeline-manager/src/db/test.rs +++ b/crates/pipeline-manager/src/db/test.rs @@ -28,7 +28,9 @@ use crate::db::types::resources_status::{ use crate::db::types::role::{MemberRole, MintableKeyRole, Role}; use crate::db::types::storage::{StorageStatus, validate_storage_status_transition}; use crate::db::types::tenant::TenantId; -use crate::db::types::user::{TenantInfo, TenantMember, UserId}; +use crate::db::types::user::{ + MembershipOrigin, TenantInfo, TenantMember, UserId, UserMembership, UserProfile, +}; use crate::db::types::utils::{ MAXIMUM_TAG_LENGTH, validate_api_key_name, validate_deployment_config, validate_pipeline_name, validate_program_config, validate_program_info, validate_runtime_config, @@ -38,7 +40,7 @@ use crate::db::types::version::Version; use crate::oidc::destination::{TenantIssuerPolicy, validate_tenant_oidc_url}; use crate::oidc::trust_name::validate_oidc_trust_name; use async_trait::async_trait; -use chrono::{TimeZone, Utc}; +use chrono::{DateTime, TimeZone, Utc}; use feldera_types::checkpoint::CheckpointMetadata; use feldera_types::config::{ DevTweaks, FtConfig, PipelineConfig, ProgramIr, ResourceConfig, RuntimeConfig, @@ -838,6 +840,131 @@ async fn tenant_creation() { assert_eq!(tenant_id_3, tenant_id_5); } +/// Explicit tenant creation is idempotent: repeating a name returns the +/// existing tenant, unchanged, with `created = false`. +#[tokio::test] +async fn explicit_tenant_creation_is_idempotent() { + let handle = test_setup().await; + let new_id = Uuid::now_v7(); + let (first, created) = handle + .db + .get_or_create_tenant(new_id, "acme", "issuer-a") + .await + .unwrap(); + assert!(created); + assert_eq!(first.id, TenantId(new_id)); + assert_eq!(first.name, "acme"); + assert_eq!(first.initial_provider, "issuer-a"); + + // A repeat, even under another provider, returns the tenant unchanged. + let (second, created) = handle + .db + .get_or_create_tenant(Uuid::now_v7(), "acme", "issuer-b") + .await + .unwrap(); + assert!(!created); + assert_eq!(second, first); +} + +/// Single-tenant retrieval resolves a name or a UUID, and a miss is an error. +#[tokio::test] +async fn tenant_retrieval_by_name_or_id() { + let handle = test_setup().await; + let (tenant, _) = handle + .db + .get_or_create_tenant(Uuid::now_v7(), "acme", "issuer-a") + .await + .unwrap(); + assert_eq!(handle.db.get_tenant("acme").await.unwrap(), tenant); + assert_eq!( + handle.db.get_tenant(&tenant.id.to_string()).await.unwrap(), + tenant + ); + assert!(matches!( + handle.db.get_tenant("absent").await.unwrap_err(), + DBError::UnknownTenantName { .. } + )); + // A UUID selector is looked up by id only, never as a name: a tenant whose + // name is a UUID string is not resolvable through that name, only its id. + let uuid_name = Uuid::now_v7().to_string(); + let (uuid_named, _) = handle + .db + .get_or_create_tenant(Uuid::now_v7(), &uuid_name, "issuer-a") + .await + .unwrap(); + assert!(matches!( + handle.db.get_tenant(&uuid_name).await.unwrap_err(), + DBError::UnknownTenantName { .. } + )); + assert_eq!( + handle + .db + .get_tenant(&uuid_named.id.to_string()) + .await + .unwrap(), + uuid_named + ); +} + +/// Membership rows record how they came into existence, a role change keeps +/// the recorded origin, and a user's memberships are listable by identity. +#[tokio::test] +async fn membership_origin_is_recorded() { + let handle = test_setup().await; + let provider = "https://idp.test".to_string(); + let (tenant, _alice, _) = handle + .db + .resolve_login( + Uuid::now_v7(), + Uuid::now_v7(), + "acme".to_string(), + provider.clone(), + "alice".to_string(), + None, + Role::Read, + Role::Admin, + MembershipOrigin::Claim, + ) + .await + .unwrap(); + let members = handle.db.list_tenant_members(tenant).await.unwrap(); + assert_eq!(members[0].origin, Some(MembershipOrigin::Claim)); + + let bob = handle + .db + .preprovision_member(Uuid::now_v7(), tenant, &provider, "bob", None, Role::Read) + .await + .unwrap(); + handle + .db + .upsert_member_role(tenant, bob, Role::Admin, MembershipOrigin::Derived) + .await + .unwrap(); + let members = handle.db.list_tenant_members(tenant).await.unwrap(); + let bob_row = members.iter().find(|m| m.user_id == bob).unwrap(); + assert_eq!(bob_row.role, Role::Admin); + assert_eq!(bob_row.origin, Some(MembershipOrigin::Api)); + + let memberships = handle + .db + .list_user_memberships(&provider, "alice") + .await + .unwrap(); + assert_eq!(memberships.len(), 1); + assert_eq!(memberships[0].tenant_id, tenant); + assert_eq!(memberships[0].name, "acme"); + // Alice's login created the tenant, so she holds `first_user_role`. + assert_eq!(memberships[0].role, Role::Admin); + assert!( + handle + .db + .list_user_memberships(&provider, "nobody") + .await + .unwrap() + .is_empty() + ); +} + /// Creation, deletion and validation of API keys. #[tokio::test] async fn api_key_store_and_validation() { @@ -1112,7 +1239,7 @@ async fn deleting_a_tenant_requires_it_to_be_empty() { .unwrap(); handle .db - .upsert_member_role(tenant, user, Role::Write) + .upsert_member_role(tenant, user, Role::Write, MembershipOrigin::Api) .await .unwrap(); @@ -1152,6 +1279,190 @@ async fn deleting_a_tenant_requires_it_to_be_empty() { )); } +/// A profile refresh is claimed once per span, and again once the token says +/// the user authenticated anew. +#[tokio::test] +async fn profile_refresh_is_claimed_once_per_authentication() { + let handle = test_setup().await; + let day = 24 * 60 * 60; + let claim = async |auth_time| { + handle + .db + .claim_profile_refresh(Uuid::now_v7(), "https://idp.example", "ada", auth_time, day) + .await + .unwrap() + }; + + // The first claim also creates the identity, which is how a platform owner + // (whose role comes from configuration, not a membership) gets a record. + assert!(claim(Some(1000)).await); + assert!(!claim(Some(1000)).await); + // A token issued from the same authentication adds nothing, however often + // it is refreshed. + assert!(!claim(Some(1000)).await); + assert!(claim(Some(2000)).await); + assert!(!claim(Some(2000)).await); + + // A second identity is tracked on its own. + assert!( + handle + .db + .claim_profile_refresh(Uuid::now_v7(), "https://idp.example", "bob", Some(1), day) + .await + .unwrap() + ); + + // A zero-length span stands for one that has elapsed: the provider is asked + // again even though nobody re-authenticated. + assert!( + handle + .db + .claim_profile_refresh(Uuid::now_v7(), "https://idp.example", "ada", None, 0) + .await + .unwrap() + ); +} + +/// A stored profile keeps an email and the provider's verdict on it together, +/// and an unverified address never displaces a verified one. +#[tokio::test] +async fn a_verified_email_outranks_a_claimed_one() { + let handle = test_setup().await; + let provider = "https://idp.example"; + let tenant = handle + .db + .get_or_create_tenant_id(Uuid::now_v7(), "acme".to_string(), provider.to_string()) + .await + .unwrap(); + let member_email = async || { + let members = handle.db.list_tenant_members(tenant).await.unwrap(); + let member = members.first().cloned().expect("one member"); + (member.email, member.email_verified, member.display_name) + }; + + // An administrator pre-provisions the membership with the address they + // believe belongs to this subject. Nobody has verified it. + let user = handle + .db + .preprovision_member( + Uuid::now_v7(), + tenant, + provider, + "ada", + Some("typo@example.com"), + Role::Write, + ) + .await + .unwrap(); + assert_eq!( + member_email().await, + (Some("typo@example.com".to_string()), false, None) + ); + + // The provider then answers with the real address and vouches for it. + handle + .db + .store_user_profile( + provider, + "ada", + &UserProfile { + email: Some("ada@example.com".to_string()), + email_verified: true, + display_name: Some("Ada Lovelace".to_string()), + }, + ) + .await + .unwrap(); + assert_eq!( + member_email().await, + ( + Some("ada@example.com".to_string()), + true, + Some("Ada Lovelace".to_string()) + ) + ); + + // Neither a later login's claim nor another pre-provisioning call may + // displace it, which would leave the verified mark beside a different + // address. + handle + .db + .get_or_create_user(Uuid::now_v7(), provider, "ada", Some("spoof@example.com")) + .await + .unwrap(); + handle + .db + .preprovision_member( + Uuid::now_v7(), + tenant, + provider, + "ada", + Some("other@example.com"), + Role::Read, + ) + .await + .unwrap(); + assert_eq!( + member_email().await, + ( + Some("ada@example.com".to_string()), + true, + Some("Ada Lovelace".to_string()) + ) + ); + + // A later answer that says nothing about the email leaves both it and the + // verdict alone, and still records the name it does carry. + handle + .db + .store_user_profile( + provider, + "ada", + &UserProfile { + email: None, + email_verified: false, + display_name: Some("A. Lovelace".to_string()), + }, + ) + .await + .unwrap(); + assert_eq!( + member_email().await, + ( + Some("ada@example.com".to_string()), + true, + Some("A. Lovelace".to_string()) + ) + ); + + // An answer the provider will not vouch for replaces both together. + handle + .db + .store_user_profile( + provider, + "ada", + &UserProfile { + email: Some("ada@newdomain.test".to_string()), + email_verified: false, + display_name: None, + }, + ) + .await + .unwrap(); + assert_eq!( + member_email().await, + ( + Some("ada@newdomain.test".to_string()), + false, + Some("A. Lovelace".to_string()) + ) + ); + assert_eq!( + user, + handle.db.list_tenant_members(tenant).await.unwrap()[0].user_id + ); +} + /// `first_user_role` sets the role of the login that creates a tenant: with /// `write`, the creator is not made admin (e.g. a shared sandbox). #[tokio::test] @@ -1172,6 +1483,7 @@ async fn rbac_first_user_role_configurable() { Some("founder@sandbox.test".to_string()), Role::Read, Role::Write, + MembershipOrigin::Claim, ) .await .unwrap(); @@ -1203,6 +1515,7 @@ async fn rbac_login_resolution_and_membership() { Some("alice@acme.test".to_string()), Role::Read, Role::Admin, + MembershipOrigin::Claim, ) .await .unwrap(); @@ -1220,6 +1533,7 @@ async fn rbac_login_resolution_and_membership() { Some("bob@acme.test".to_string()), Role::Read, Role::Admin, + MembershipOrigin::Claim, ) .await .unwrap(); @@ -1238,6 +1552,7 @@ async fn rbac_login_resolution_and_membership() { None, Role::Read, Role::Admin, + MembershipOrigin::Claim, ) .await .unwrap(); @@ -1256,7 +1571,7 @@ async fn rbac_login_resolution_and_membership() { // An admin can change bob's role to write. handle .db - .upsert_member_role(tenant, bob, Role::Write) + .upsert_member_role(tenant, bob, Role::Write, MembershipOrigin::Api) .await .unwrap(); let members = handle.db.list_tenant_members(tenant).await.unwrap(); @@ -1331,6 +1646,7 @@ async fn oidc_trust_rejects_an_unreachable_issuer() { None, Role::Read, Role::Admin, + MembershipOrigin::Claim, ) .await .unwrap() @@ -1416,6 +1732,7 @@ async fn oidc_trust_matching_and_issuer_gate() { None, Role::Read, Role::Admin, + MembershipOrigin::Claim, ) .await .unwrap() @@ -1563,6 +1880,7 @@ async fn preprovision_member_survives_first_login() { None, Role::Read, Role::Admin, + MembershipOrigin::Claim, ) .await .unwrap(); @@ -1598,6 +1916,7 @@ async fn preprovision_member_survives_first_login() { Some("carol@acme.test".to_string()), Role::Read, Role::Admin, + MembershipOrigin::Claim, ) .await .unwrap(); @@ -4322,6 +4641,30 @@ async fn check_rbac_action( impl_response.sort_by(|a, b| a.id.cmp(&b.id)); assert_eq!(model_response, impl_response); } + RbacAction::GetTenantById(tenant_id) => { + // Not seeded first, so the id-selector miss path is exercised too. + let selector = tenant_id.0.to_string(); + let model_response = model.get_tenant(&selector).await; + let impl_response = handle.db.get_tenant(&selector).await; + check_responses(i, model_response, impl_response); + } + RbacAction::GetTenantByName(name) => { + let model_response = model.get_tenant(&name).await; + let impl_response = handle.db.get_tenant(&name).await; + check_responses(i, model_response, impl_response); + } + RbacAction::CreateTenant(name, provider) => { + // Both sides get the same fresh id; only a creation uses it. The + // provider varies so that the exists path returning the stored + // rather than the passed provider is observable. + let new_id = Uuid::now_v7(); + let model_response = model.get_or_create_tenant(new_id, &name, &provider).await; + let impl_response = handle + .db + .get_or_create_tenant(new_id, &name, &provider) + .await; + check_responses(i, model_response, impl_response); + } RbacAction::GetOrCreateUser((provider, subject), email) => { let id = user_id_for_identity(&provider, &subject); let model_response = model @@ -4341,17 +4684,70 @@ async fn check_rbac_action( let impl_response = handle.db.list_tenant_members(tenant_id).await; check_responses(i, model_response, impl_response); } + RbacAction::EnrollInExistingTenants((provider, subject), names, role) => { + let new_user_id = user_id_for_identity(&provider, &subject); + let model_response = model + .enroll_in_existing_tenants( + new_user_id, + &provider, + &subject, + None, + &names, + role.role(), + MembershipOrigin::Api, + ) + .await; + let impl_response = handle + .db + .enroll_in_existing_tenants( + new_user_id, + &provider, + &subject, + None, + &names, + role.role(), + MembershipOrigin::Api, + ) + .await; + check_responses(i, model_response, impl_response); + } + RbacAction::ListUserMemberships((provider, subject)) => { + // Sorted by id on both sides: Postgres collates names by locale, + // Rust by bytes, and the order is not what this action checks. + let mut model_response = model + .list_user_memberships(&provider, &subject) + .await + .unwrap(); + let mut impl_response = handle + .db + .list_user_memberships(&provider, &subject) + .await + .unwrap(); + model_response.sort_by_key(|m| m.tenant_id); + impl_response.sort_by_key(|m| m.tenant_id); + assert_eq!(model_response, impl_response); + } RbacAction::UpsertMemberRole(tenant_id, (provider, subject), role) => { let user_id = user_id_for_identity(&provider, &subject); create_tenants_if_not_exists(model, handle, tenant_id) .await .unwrap(); let model_response = model - .upsert_member_role(tenant_id, UserId(user_id), role.role()) + .upsert_member_role( + tenant_id, + UserId(user_id), + role.role(), + MembershipOrigin::Api, + ) .await; let impl_response = handle .db - .upsert_member_role(tenant_id, UserId(user_id), role.role()) + .upsert_member_role( + tenant_id, + UserId(user_id), + role.role(), + MembershipOrigin::Api, + ) .await; check_responses(i, model_response, impl_response); } @@ -4432,11 +4828,23 @@ enum RbacAction { DeleteTenant(TenantId), // Users and tenant memberships ListTenants, + GetTenantById(TenantId), + GetTenantByName(#[proptest(strategy = "limited_tenant_name()")] String), + CreateTenant( + #[proptest(strategy = "limited_tenant_name()")] String, + #[proptest(strategy = "limited_issuer()")] String, + ), GetOrCreateUser( #[proptest(strategy = "limited_identity()")] (String, String), #[proptest(strategy = "limited_email()")] Option, ), ListTenantMembers(TenantId), + ListUserMemberships(#[proptest(strategy = "limited_identity()")] (String, String)), + EnrollInExistingTenants( + #[proptest(strategy = "limited_identity()")] (String, String), + #[proptest(strategy = "prop::collection::vec(limited_tenant_name(), 0..3)")] Vec, + MemberRole, + ), UpsertMemberRole( TenantId, #[proptest(strategy = "limited_identity()")] (String, String), @@ -5587,6 +5995,16 @@ fn rbac_db_impl_behaves_like_model() { } } +/// A user in the model: the identity's id, what the provider says about the +/// person, and what the last profile refresh covered. +#[derive(Clone, Debug)] +struct UserRecord { + id: UserId, + profile: UserProfile, + refreshed_at: Option>, + refreshed_auth_time: Option, +} + /// Model of the database to which its operations are compared. #[derive(Debug)] struct DbModel { @@ -5597,10 +6015,9 @@ struct DbModel { pub cluster_events: BTreeMap, /// Keyed by tenant and name, mirroring the table's uniqueness. pub oidc_trusts: BTreeMap<(TenantId, String), OidcTrustDescr>, - /// Keyed by the `(provider, subject)` identity, holding the user's id and - /// the email last seen for it. - pub users: BTreeMap<(String, String), (UserId, Option)>, - pub memberships: BTreeMap<(TenantId, UserId), Role>, + /// Keyed by the `(provider, subject)` identity. + pub users: BTreeMap<(String, String), UserRecord>, + pub memberships: BTreeMap<(TenantId, UserId), (Role, MembershipOrigin)>, } #[async_trait] @@ -6072,8 +6489,72 @@ impl Storage for Mutex { ); } - async fn create_tenant(&self, id: Uuid, _name: &str, _provider: &str) -> DBResult { - Ok(TenantId(id)) + async fn find_tenant_id_by_name(&self, name: &str) -> DBResult> { + let s = self.lock().await; + Ok(s.tenants + .iter() + .find(|(_, t)| t.tenant == name) + .map(|(id, _)| *id)) + } + + async fn get_tenant(&self, selector: &str) -> DBResult { + let s = self.lock().await; + let matched = if let Ok(uuid) = Uuid::parse_str(selector) { + s.tenants.get_key_value(&TenantId(uuid)) + } else { + s.tenants.iter().find(|(_, t)| t.tenant == selector) + }; + matched + .map(|(id, t)| TenantInfo { + id: *id, + name: t.tenant.clone(), + initial_provider: t.initial_provider.clone(), + }) + .ok_or(DBError::UnknownTenantName { + name: selector.to_string(), + }) + } + + async fn get_or_create_tenant( + &self, + new_id: Uuid, + name: &str, + provider: &str, + ) -> DBResult<(TenantInfo, bool)> { + let mut s = self.lock().await; + if let Some((id, t)) = s.tenants.iter().find(|(_, t)| t.tenant == name) { + return Ok(( + TenantInfo { + id: *id, + name: t.tenant.clone(), + initial_provider: t.initial_provider.clone(), + }, + false, + )); + } + let id = TenantId(new_id); + // Postgres would raise a primary-key violation here; callers mint a + // fresh v7 id, so crash rather than diverge from it silently. + assert!( + !s.tenants.contains_key(&id), + "get_or_create_tenant: id {id} already exists under another name" + ); + s.tenants.insert( + id, + TenantRecord { + id, + tenant: name.to_string(), + initial_provider: provider.to_string(), + }, + ); + Ok(( + TenantInfo { + id, + name: name.to_string(), + initial_provider: provider.to_string(), + }, + true, + )) } async fn get_tenant_name(&self, tenant_id: TenantId) -> Result { @@ -6295,21 +6776,9 @@ impl Storage for Mutex { } async fn resolve_tenant_selector(&self, selector: &str) -> DBResult { - let s = self.lock().await; - if let Ok(uuid) = Uuid::parse_str(selector) { - return s.tenants.keys().find(|id| id.0 == uuid).copied().ok_or( - DBError::UnknownTenantName { - name: selector.to_string(), - }, - ); - } - s.tenants - .iter() - .find(|(_, t)| t.tenant == selector) - .map(|(id, _)| *id) - .ok_or(DBError::UnknownTenantName { - name: selector.to_string(), - }) + // Mirrors the production shape, where the selector semantics live in + // one place: `resolve_tenant_selector` delegates to `get_tenant`. + Ok(self.get_tenant(selector).await?.id) } async fn delete_tenant(&self, tenant_id: TenantId) -> DBResult<()> { @@ -6400,6 +6869,7 @@ impl Storage for Mutex { _email: Option, default_role: Role, _first_user_role: Role, + _origin: MembershipOrigin, ) -> DBResult<(TenantId, UserId, Role)> { Ok((TenantId(Uuid::nil()), UserId(Uuid::nil()), default_role)) } @@ -6414,35 +6884,116 @@ impl Storage for Mutex { let mut s = self.lock().await; let key = (provider.to_string(), subject.to_string()); match s.users.get_mut(&key) { - Some((id, stored_email)) => { - // COALESCE: a token without an email leaves the stored one be. - if let Some(email) = email { - *stored_email = Some(email.to_string()); + Some(user) => { + // COALESCE: a token without an email leaves the stored one be, + // and an address the provider vouched for outranks this one. + if let Some(email) = email + && !user.profile.email_verified + { + user.profile.email = Some(email.to_string()); } - Ok(*id) + Ok(user.id) } None => { let id = UserId(new_id); - s.users.insert(key, (id, email.map(str::to_string))); + s.users.insert( + key, + UserRecord { + id, + profile: UserProfile { + email: email.map(str::to_string), + ..UserProfile::default() + }, + refreshed_at: None, + refreshed_auth_time: None, + }, + ); Ok(id) } } } + async fn claim_profile_refresh( + &self, + new_id: Uuid, + provider: &str, + subject: &str, + auth_time: Option, + ttl_seconds: i64, + ) -> DBResult { + let mut s = self.lock().await; + let now = Utc::now(); + let key = (provider.to_string(), subject.to_string()); + let Some(user) = s.users.get_mut(&key) else { + s.users.insert( + key, + UserRecord { + id: UserId(new_id), + profile: UserProfile::default(), + refreshed_at: Some(now), + refreshed_auth_time: auth_time, + }, + ); + return Ok(true); + }; + let aged_out = user + .refreshed_at + .is_none_or(|at| now - at > chrono::Duration::seconds(ttl_seconds)); + let reauthenticated = match (auth_time, user.refreshed_auth_time) { + (Some(now), Some(then)) => now > then, + (Some(_), None) => true, + (None, _) => false, + }; + if !aged_out && !reauthenticated { + return Ok(false); + } + user.refreshed_at = Some(now); + user.refreshed_auth_time = auth_time; + Ok(true) + } + + async fn store_user_profile( + &self, + provider: &str, + subject: &str, + profile: &UserProfile, + ) -> DBResult<()> { + let mut s = self.lock().await; + let Some(user) = s + .users + .get_mut(&(provider.to_string(), subject.to_string())) + else { + return Ok(()); + }; + // An absent field leaves the stored one be, and the verdict travels + // with the address it is about. + if profile.email.is_some() { + user.profile.email = profile.email.clone(); + user.profile.email_verified = profile.email_verified; + } + if profile.display_name.is_some() { + user.profile.display_name = profile.display_name.clone(); + } + Ok(()) + } + async fn list_tenant_members(&self, tenant_id: TenantId) -> DBResult> { let s = self.lock().await; let mut members: Vec = s .memberships .iter() .filter(|((t, _), _)| *t == tenant_id) - .filter_map(|((_, user_id), role)| { - s.users.iter().find(|(_, (id, _))| id == user_id).map( - |((provider, subject), (id, email))| TenantMember { - user_id: *id, + .filter_map(|((_, user_id), (role, origin))| { + s.users.iter().find(|(_, user)| user.id == *user_id).map( + |((provider, subject), user)| TenantMember { + user_id: user.id, provider: provider.clone(), subject: subject.clone(), - email: email.clone(), + email: user.profile.email.clone(), + email_verified: user.profile.email_verified, + display_name: user.profile.display_name.clone(), role: *role, + origin: Some(*origin), }, ) }) @@ -6461,22 +7012,84 @@ impl Storage for Mutex { Ok(members) } + async fn enroll_in_existing_tenants( + &self, + new_user_id: Uuid, + provider: &str, + subject: &str, + email: Option<&str>, + names: &[String], + role: Role, + origin: MembershipOrigin, + ) -> DBResult<()> { + let user_id = self + .get_or_create_user(new_user_id, provider, subject, email) + .await?; + let mut s = self.lock().await; + for name in names { + let Some(tenant_id) = s + .tenants + .iter() + .find(|(_, t)| t.tenant == *name) + .map(|(id, _)| *id) + else { + continue; + }; + // Enroll only where no membership exists; never touch a role. + s.memberships + .entry((tenant_id, user_id)) + .or_insert((role, origin)); + } + Ok(()) + } + + async fn list_user_memberships( + &self, + provider: &str, + subject: &str, + ) -> DBResult> { + let s = self.lock().await; + let Some(user) = s.users.get(&(provider.to_string(), subject.to_string())) else { + return Ok(vec![]); + }; + let mut memberships: Vec = s + .memberships + .iter() + .filter(|((_, u), _)| *u == user.id) + .filter_map(|((t, _), (role, _))| { + s.tenants.get(t).map(|rec| UserMembership { + tenant_id: *t, + name: rec.tenant.clone(), + role: *role, + }) + }) + .collect(); + memberships.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(memberships) + } + async fn upsert_member_role( &self, tenant_id: TenantId, user_id: UserId, role: Role, + origin: MembershipOrigin, ) -> DBResult<()> { let mut s = self.lock().await; if !s.tenants.contains_key(&tenant_id) { return Err(DBError::UnknownTenant { tenant_id }); } - if !s.users.values().any(|(id, _)| *id == user_id) { + if !s.users.values().any(|user| user.id == user_id) { return Err(DBError::UnknownUser { user_id: user_id.to_string(), }); } - s.memberships.insert((tenant_id, user_id), role); + // A conflict updates the role and keeps the recorded origin, as the + // SQL upsert does. + s.memberships + .entry((tenant_id, user_id)) + .and_modify(|(r, _)| *r = role) + .or_insert((role, origin)); Ok(()) } @@ -6502,7 +7115,8 @@ impl Storage for Mutex { let user_id = self .get_or_create_user(new_user_id, provider, subject, email) .await?; - self.upsert_member_role(tenant_id, user_id, role).await?; + self.upsert_member_role(tenant_id, user_id, role, MembershipOrigin::Api) + .await?; Ok(user_id) } diff --git a/crates/pipeline-manager/src/db/types/user.rs b/crates/pipeline-manager/src/db/types/user.rs index d438386e8bd..941941f0cd5 100644 --- a/crates/pipeline-manager/src/db/types/user.rs +++ b/crates/pipeline-manager/src/db/types/user.rs @@ -4,6 +4,7 @@ use crate::db::types::role::Role; use crate::db::types::tenant::TenantId; use serde::{Deserialize, Serialize}; use std::fmt; +use std::str::FromStr; use utoipa::ToSchema; use uuid::Uuid; @@ -24,6 +25,67 @@ impl fmt::Display for UserId { } } +/// How a membership row came into existence, kept for audit. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum MembershipOrigin { + /// The user's own token listed the tenant in its claim. + Claim, + /// The personal or issuer tenant derivation produced it. + Derived, + /// Granted through the RBAC endpoints. + Api, +} + +impl MembershipOrigin { + pub fn as_str(&self) -> &'static str { + match self { + MembershipOrigin::Claim => "claim", + MembershipOrigin::Derived => "derived", + MembershipOrigin::Api => "api", + } + } +} + +/// Parsing is total: an unknown string is an error, because the column's +/// CHECK constraint makes one data corruption, which must surface rather than +/// read as a row that predates provenance tracking. +impl FromStr for MembershipOrigin { + type Err = InvalidMembershipOrigin; + + fn from_str(input: &str) -> Result { + match input { + "claim" => Ok(MembershipOrigin::Claim), + "derived" => Ok(MembershipOrigin::Derived), + "api" => Ok(MembershipOrigin::Api), + other => Err(InvalidMembershipOrigin(other.to_string())), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InvalidMembershipOrigin(pub String); + +impl fmt::Display for InvalidMembershipOrigin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "invalid membership origin '{}'", self.0) + } +} + +/// What an identity provider reports about a person, read from its OIDC +/// UserInfo endpoint (see [`crate::oidc::userinfo`]). Every field is optional: +/// providers differ in what they publish, and a token's scopes decide how much +/// of it Feldera is allowed to see. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct UserProfile { + pub email: Option, + /// Whether the provider vouches for the email. False unless it says so, so + /// that a provider saying nothing never reads as an endorsement. + pub email_verified: bool, + /// The person's name, as the provider spells it. + pub display_name: Option, +} + /// A member of a tenant, as returned by the user-management API. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] pub struct TenantMember { @@ -32,14 +94,40 @@ pub struct TenantMember { pub provider: String, /// OIDC subject. pub subject: String, - /// Email, if the identity provider supplied one. + /// Email, if the identity provider supplied one or an administrator + /// recorded one when pre-provisioning the membership. #[serde(default)] pub email: Option, + /// Whether the identity provider vouches for `email`. False until the + /// provider has been asked, and for providers that say nothing either way. + /// An email an administrator typed is never verified, so this is what + /// separates an address the provider stands behind from a claim about one. + #[serde(default)] + pub email_verified: bool, + /// The member's name, as the identity provider spells it; `null` until the + /// provider has been asked. + #[serde(default)] + pub display_name: Option, + /// The user's role within this tenant. + pub role: Role, + /// How the membership came into existence. `null` for rows created before + /// provenance tracking. + #[serde(default)] + pub origin: Option, +} + +/// One tenant a user may act in, as surfaced to that user (e.g. in the +/// session payload that drives the web console's tenant switcher). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct UserMembership { + pub tenant_id: TenantId, + /// The tenant's name. + pub name: String, /// The user's role within this tenant. pub role: Role, } -/// A tenant, as returned by the platform (owner-only) tenant list. +/// A tenant, as returned by the owner-only tenant endpoints. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] pub struct TenantInfo { pub id: TenantId, diff --git a/crates/pipeline-manager/src/error.rs b/crates/pipeline-manager/src/error.rs index ce6ea61d903..87b8620816c 100644 --- a/crates/pipeline-manager/src/error.rs +++ b/crates/pipeline-manager/src/error.rs @@ -27,7 +27,7 @@ use crate::compiler::error::CompilerError; use crate::db::error::DBError; use crate::runner::error::RunnerError; use actix_web::{ - HttpResponse, HttpResponseBuilder, ResponseError, body::BoxBody, http::StatusCode, + HttpResponse, HttpResponseBuilder, ResponseError, body::BoxBody, http::StatusCode, http::header, }; use feldera_types::error::{DetailedError, ErrorResponse}; use openssl::error::ErrorStack; @@ -125,6 +125,30 @@ impl Display for ManagerError { } } +/// Seconds a client should wait before retrying a 503. Matches the runner's +/// fastest pipeline-status probe interval and its pipeline-descriptor cache +/// TTL: the soonest an unavailable pipeline can be observed healthy again. +pub(crate) const SERVICE_UNAVAILABLE_RETRY_AFTER_SECONDS: u32 = 5; + +/// Build the JSON error response. A 503 carries `Retry-After` so clients +/// back off for a server-chosen interval instead of guessing: 503s signal a +/// transient condition (pipeline pod rescheduling, runner restart) that +/// resolves within seconds. +pub(crate) fn json_error_response(error: &E) -> HttpResponse +where + E: DetailedError, +{ + let status = error.status_code(); + let mut builder = HttpResponseBuilder::new(status); + if status == StatusCode::SERVICE_UNAVAILABLE { + builder.insert_header(( + header::RETRY_AFTER, + SERVICE_UNAVAILABLE_RETRY_AFTER_SECONDS.to_string(), + )); + } + builder.json(ErrorResponse::from_error(error)) +} + impl ResponseError for ManagerError { fn status_code(&self) -> StatusCode { match self { @@ -138,7 +162,7 @@ impl ResponseError for ManagerError { } fn error_response(&self) -> HttpResponse { - HttpResponseBuilder::new(self.status_code()).json(ErrorResponse::from_error(self)) + json_error_response(self) } } @@ -168,3 +192,30 @@ impl From for ErrorResponse { ErrorResponse::from_error_nolog(&val) } } + +#[cfg(test)] +mod test { + use super::*; + use crate::runner::error::RunnerError; + + #[test] + fn service_unavailable_carries_retry_after() { + let err = ManagerError::from(RunnerError::PipelineUnavailable { + pipeline_name: "p".to_string(), + }); + let resp = err.error_response(); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + resp.headers().get(header::RETRY_AFTER).unwrap(), + &SERVICE_UNAVAILABLE_RETRY_AFTER_SECONDS.to_string() + ); + } + + #[test] + fn other_statuses_carry_no_retry_after() { + let err = ManagerError::from(RunnerError::AutomatonMissingProgramInfo); + let resp = err.error_response(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(resp.headers().get(header::RETRY_AFTER).is_none()); + } +} diff --git a/crates/pipeline-manager/src/oidc.rs b/crates/pipeline-manager/src/oidc.rs index 10a4307afcd..f4dc28934d4 100644 --- a/crates/pipeline-manager/src/oidc.rs +++ b/crates/pipeline-manager/src/oidc.rs @@ -3,3 +3,4 @@ pub mod destination; pub mod fetch; pub mod trust_name; +pub mod userinfo; diff --git a/crates/pipeline-manager/src/oidc/destination.rs b/crates/pipeline-manager/src/oidc/destination.rs index fa5e4148d30..b347441b07f 100644 --- a/crates/pipeline-manager/src/oidc/destination.rs +++ b/crates/pipeline-manager/src/oidc/destination.rs @@ -127,11 +127,79 @@ pub fn validate_tenant_oidc_url(url: &str, policy: TenantIssuerPolicy) -> Result } } +/// Validate a URL that a request will carry a bearer credential to. +/// +/// A token read off the wire is replayable as the user it belongs to, so a +/// credential-carrying request must be encrypted. This is a stricter rule than +/// the one governing key fetches: a JWKS is signed and its exposure costs +/// nothing, whereas the UserInfo request presents the caller's own live access +/// token (OpenID Connect Core 1.0 §5.3.1), so a discovery document that names +/// an `http://` endpoint must not be followed. +/// +/// Loopback is the exception. Nothing off the host can observe it, and an +/// identity provider on `localhost` is the ordinary shape of a development +/// deployment. The check is on the URL alone; see [`validate_tenant_oidc_url`] +/// for the address policy that additionally applies to issuers a tenant chose. +pub fn validate_credential_destination(url: &str) -> Result<(), OidcUrlError> { + let parsed = Url::parse(url).map_err(|e| OidcUrlError::Malformed(e.to_string()))?; + if parsed.scheme() == "https" { + return Ok(()); + } + let host = parsed.host().ok_or(OidcUrlError::NoHost)?; + let loopback = match host { + // RFC 6761 reserves `localhost`, so it resolves to a loopback address. + Host::Domain(name) => name.eq_ignore_ascii_case("localhost"), + Host::Ipv4(v4) => v4.is_loopback(), + Host::Ipv6(v6) => v6.is_loopback(), + }; + if loopback { + Ok(()) + } else { + Err(OidcUrlError::NotHttps(parsed.scheme().to_string())) + } +} + #[cfg(test)] mod test { use super::*; use std::str::FromStr; + /// A URL that will carry a bearer token must be encrypted, except on + /// loopback, which nothing off the host can observe. + #[test] + fn a_credential_destination_must_be_https_unless_it_is_loopback() { + for url in [ + "https://idp.example.com/oauth2/userInfo", + "https://10.0.0.4/userinfo", + "http://localhost:9876/userinfo", + "http://LOCALHOST:9876/userinfo", + "http://127.0.0.1:9876/userinfo", + "http://[::1]:9876/userinfo", + ] { + assert!(validate_credential_destination(url).is_ok(), "{url}"); + } + + for url in [ + "http://idp.example.com/oauth2/userInfo", + // A private address is still observable on that network. + "http://10.0.0.4/userinfo", + "http://192.168.1.9/userinfo", + // Not loopback, however much it reads like it. + "http://localhost.evil.example.com/userinfo", + "http://notlocalhost/userinfo", + ] { + assert!( + matches!( + validate_credential_destination(url), + Err(OidcUrlError::NotHttps(_)) + ), + "{url}" + ); + } + + assert!(validate_credential_destination("not a url").is_err()); + } + fn public(ip: &str) -> bool { is_public_ip(IpAddr::from_str(ip).unwrap()) } diff --git a/crates/pipeline-manager/src/oidc/fetch.rs b/crates/pipeline-manager/src/oidc/fetch.rs index 1fc04b40ff7..d31de4446cc 100644 --- a/crates/pipeline-manager/src/oidc/fetch.rs +++ b/crates/pipeline-manager/src/oidc/fetch.rs @@ -15,8 +15,12 @@ use std::sync::Arc; use std::time::Duration; #[derive(Deserialize)] -struct OidcDiscoveryDocument { - jwks_uri: String, +pub(crate) struct OidcDiscoveryDocument { + pub jwks_uri: String, + /// Absent for providers that publish no UserInfo endpoint. Optional in the + /// discovery metadata (OpenID Connect Discovery 1.0 §3). + #[serde(default)] + pub userinfo_endpoint: Option, } /// Timeout for OIDC discovery / JWKS HTTP requests. @@ -89,23 +93,71 @@ pub(crate) fn oidc_http_client( } } -/// Fetch OIDC discovery document and extract `jwks_uri`. -pub(crate) async fn fetch_jwks_uri_from_discovery( +/// The clients OIDC fetches use, built once per process. +/// +/// [`oidc_http_client`] costs tens of milliseconds per call, because +/// `use_rustls_tls()` loads and parses the platform's root certificate store +/// each time. Building one per fetch put that on the authentication path twice +/// per JWKS resolution. Only two clients differ, by whether the destination +/// policy restricts DNS, so hold both. Reuse also keeps the connection pool, +/// so a repeat fetch to the same issuer skips the TLS handshake. +pub(crate) struct OidcClients { + /// Resolves any address: either the operator named this issuer, or the + /// installation permits internal ones. + unrestricted: reqwest::Client, + /// Drops every address outside [`is_public_ip`]. + public_addrs_only: reqwest::Client, +} + +impl OidcClients { + pub(crate) fn new(extra_roots: &[reqwest::Certificate]) -> Result { + Ok(Self { + unrestricted: oidc_http_client(OidcDestination::OperatorConfigured, extra_roots)?, + public_addrs_only: oidc_http_client( + OidcDestination::TenantRegistered(TenantIssuerPolicy::PublicHttpsOnly), + extra_roots, + )?, + }) + } + + pub(crate) fn for_destination(&self, destination: OidcDestination) -> &reqwest::Client { + match destination { + OidcDestination::TenantRegistered(TenantIssuerPolicy::PublicHttpsOnly) => { + &self.public_addrs_only + } + _ => &self.unrestricted, + } + } +} + +/// Fetch an issuer's OIDC discovery document. +pub(crate) async fn fetch_discovery_document( issuer: &str, destination: OidcDestination, - extra_roots: &[reqwest::Certificate], -) -> Result { + clients: &OidcClients, +) -> Result { let discovery_url = format!( "{}/.well-known/openid-configuration", issuer.trim_end_matches('/') ); - let discovery: OidcDiscoveryDocument = oidc_http_client(destination, extra_roots)? + clients + .for_destination(destination) .get(&discovery_url) .send() .await? .json() - .await?; - Ok(discovery.jwks_uri) + .await +} + +/// Fetch OIDC discovery document and extract `jwks_uri`. +pub(crate) async fn fetch_jwks_uri_from_discovery( + issuer: &str, + destination: OidcDestination, + clients: &OidcClients, +) -> Result { + Ok(fetch_discovery_document(issuer, destination, clients) + .await? + .jwks_uri) } /// Fetch and parse the RSA JWKS for a federated `issuer` (discovery then keys), @@ -119,9 +171,9 @@ pub(crate) async fn fetch_jwks_uri_from_discovery( pub(crate) async fn fetch_issuer_jwks( issuer: &str, destination: OidcDestination, - extra_roots: &[reqwest::Certificate], + clients: &OidcClients, ) -> Result, AuthError> { - let jwks_uri = fetch_jwks_uri_from_discovery(issuer, destination, extra_roots) + let jwks_uri = fetch_jwks_uri_from_discovery(issuer, destination, clients) .await .map_err(|e| AuthError::JwkShape(format!("OIDC discovery failed: {e}")))?; if let OidcDestination::TenantRegistered(policy) = destination { @@ -131,9 +183,8 @@ pub(crate) async fn fetch_issuer_jwks( )) })?; } - let client = oidc_http_client(destination, extra_roots) - .map_err(|e| AuthError::JwkShape(format!("OIDC client build: {e}")))?; - let keys_json: Value = client + let keys_json: Value = clients + .for_destination(destination) .get(&jwks_uri) .send() .await @@ -143,3 +194,59 @@ pub(crate) async fn fetch_issuer_jwks( .map_err(|e| AuthError::JwkShape(format!("JWKS parse failed: {e}")))?; parse_rsa_jwks(&keys_json) } + +#[cfg(test)] +mod test { + use super::*; + use crate::oidc::destination::TenantIssuerPolicy; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + /// Holding the clients must not lose the destination policy: the one a + /// tenant-registered issuer gets still refuses to resolve a loopback host, + /// and the operator-configured one still reaches it. + #[tokio::test] + async fn a_held_client_keeps_its_destination_policy() { + crate::ensure_default_crypto_provider(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/probe")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + // The mock server binds an address; ask for it by name so the resolver, + // not the URL validator, is what decides. + let url = format!("http://localhost:{}/probe", server.address().port()); + let clients = OidcClients::new(&[]).unwrap(); + + assert!( + clients + .for_destination(OidcDestination::OperatorConfigured) + .get(&url) + .send() + .await + .is_ok() + ); + let restricted = clients + .for_destination(OidcDestination::TenantRegistered( + TenantIssuerPolicy::PublicHttpsOnly, + )) + .get(&url) + .send() + .await; + assert!(restricted.is_err(), "loopback must not resolve"); + + // An installation that permits internal issuers shares the unrestricted + // client, so it reaches the same address. + assert!( + clients + .for_destination(OidcDestination::TenantRegistered( + TenantIssuerPolicy::AllowInternal + )) + .get(&url) + .send() + .await + .is_ok() + ); + } +} diff --git a/crates/pipeline-manager/src/oidc/userinfo.rs b/crates/pipeline-manager/src/oidc/userinfo.rs new file mode 100644 index 00000000000..b53758cbeed --- /dev/null +++ b/crates/pipeline-manager/src/oidc/userinfo.rs @@ -0,0 +1,445 @@ +//! Keeping a user's profile current from the provider's UserInfo endpoint. +//! +//! An access token is a poor place to read a person's name and email from. The +//! claims a provider puts there vary, and some put none: an AWS Cognito access +//! token carries neither, however many scopes it was granted. The UserInfo +//! endpoint (OpenID Connect Core 1.0 §5.3) is where a provider answers those +//! questions for the identity a token belongs to, so that is where Feldera asks. +//! +//! Feldera authenticates every request on its own and holds no session, so +//! there is no "login" moment to hang the fetch on. The token's `auth_time` +//! claim supplies one: it records when the user actually authenticated and +//! survives token refresh, so a token bearing a newer `auth_time` than the last +//! refresh covered marks a fresh login, which is when a changed email appears. +//! Providers that publish no `auth_time` fall back to +//! [`PROFILE_REFRESH_TTL_SECONDS`]. + +use crate::db::types::user::UserProfile; +use crate::oidc::destination::{ + OidcUrlError, validate_credential_destination, validate_tenant_oidc_url, +}; +use crate::oidc::fetch::{OidcClients, OidcDestination, fetch_discovery_document}; +use cached::{Cached, TimedSizedCache}; +use reqwest::StatusCode; +use serde::Deserialize; +use std::fmt; + +/// How long a refresh attempt stands before the next one is due. Applies to +/// attempts that found nothing and to those that failed, so an unreachable or +/// UserInfo-less provider costs one request a day per user rather than one per +/// login request. +pub const PROFILE_REFRESH_TTL_SECONDS: u64 = 24 * 60 * 60; + +/// How many identities and issuers the refresh bookkeeping tracks. Evicting an +/// entry only costs a redundant refresh, so this bounds memory rather than +/// correctness. +const USER_PROFILE_CACHE_CAPACITY: usize = 4096; + +/// A provider's answer for the identity behind an access token. +#[derive(Deserialize)] +struct UserInfoResponse { + /// Identifies whose profile this is. Verified against the token's own + /// subject before anything is stored (OpenID Connect Core 1.0 §5.3.2). + sub: String, + email: Option, + #[serde(default, deserialize_with = "deserialize_bool_or_string")] + email_verified: bool, + name: Option, + preferred_username: Option, +} + +impl From for UserProfile { + fn from(response: UserInfoResponse) -> UserProfile { + UserProfile { + email: response.email, + email_verified: response.email_verified, + // `name` is the person's full name; `preferred_username` is + // whatever handle the provider assigned, which for a federated + // Cognito identity is a machine-generated string. Prefer the name. + display_name: response.name.or(response.preferred_username), + } + } +} + +/// `email_verified` is a boolean in OpenID Connect Core 1.0 §5.1, but AWS +/// Cognito answers with the strings `"true"` and `"false"`. Accept both. Any +/// other shape reads as unverified, because one odd field must not discard the +/// name and email alongside it. +pub(crate) fn deserialize_bool_or_string<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok(match serde_json::Value::deserialize(deserializer)? { + serde_json::Value::Bool(verified) => verified, + serde_json::Value::String(text) => text.parse::().unwrap_or(false), + _ => false, + }) +} + +#[derive(Debug)] +pub(crate) enum UserInfoError { + /// The issuer publishes no `userinfo_endpoint`. + NotOffered, + Request(reqwest::Error), + Status(StatusCode), + /// The endpoint is not a destination this issuer may name. + Destination(OidcUrlError), + /// The answer describes a different identity than the token does. A + /// provider that does this is either broken or being impersonated; either + /// way the profile must not be stored against this user. + SubjectMismatch, +} + +impl fmt::Display for UserInfoError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + UserInfoError::NotOffered => f.write_str("issuer publishes no userinfo_endpoint"), + UserInfoError::Request(e) => write!(f, "UserInfo request failed: {e}"), + UserInfoError::Status(status) => write!(f, "UserInfo answered {status}"), + UserInfoError::Destination(e) => { + write!(f, "userinfo_endpoint is not a permitted destination: {e}") + } + UserInfoError::SubjectMismatch => { + f.write_str("UserInfo answered for a different subject than the token names") + } + } + } +} + +impl std::error::Error for UserInfoError {} + +/// Read the profile the provider holds for the identity behind `access_token`. +/// +/// The endpoint comes out of the issuer's discovery document, which the issuer +/// controls, so two checks apply. Every mode requires an encrypted destination, +/// because this request carries the caller's token; a tenant-registered issuer +/// is additionally held to the same address policy here as its `jwks_uri` is in +/// [`crate::oidc::fetch::fetch_issuer_jwks`]. +pub(crate) async fn fetch_user_profile( + userinfo_endpoint: &str, + subject: &str, + access_token: &str, + destination: OidcDestination, + clients: &OidcClients, +) -> Result { + // This request presents the caller's own access token, so the endpoint has + // to be encrypted whoever chose the issuer. The issuer writes its own + // discovery document, and an operator naming a trusted issuer is not + // vouching for every URL that document contains. + validate_credential_destination(userinfo_endpoint).map_err(UserInfoError::Destination)?; + if let OidcDestination::TenantRegistered(policy) = destination { + validate_tenant_oidc_url(userinfo_endpoint, policy).map_err(UserInfoError::Destination)?; + } + let response = clients + .for_destination(destination) + .get(userinfo_endpoint) + .bearer_auth(access_token) + .send() + .await + .map_err(UserInfoError::Request)?; + if !response.status().is_success() { + return Err(UserInfoError::Status(response.status())); + } + let info: UserInfoResponse = response.json().await.map_err(UserInfoError::Request)?; + if info.sub != subject { + return Err(UserInfoError::SubjectMismatch); + } + Ok(info.into()) +} + +/// Which identities have had a recent refresh, and where each issuer's UserInfo +/// endpoint lives. +pub(crate) struct UserProfileCache { + /// `(provider, subject)` to the token `auth_time` the last attempt covered, + /// `None` when that token carried none. Entries expire after + /// [`PROFILE_REFRESH_TTL_SECONDS`], which is what makes a refresh due again + /// for a provider that publishes no `auth_time`. + attempts: TimedSizedCache<(String, String), Option>, + /// Issuer to its `userinfo_endpoint`, `None` when it publishes none. Cached + /// for the same span, so a provider that gains one is picked up within a + /// day without a discovery fetch per login. + endpoints: TimedSizedCache>, +} + +impl UserProfileCache { + pub(crate) fn new() -> Self { + Self { + attempts: TimedSizedCache::with_size_and_lifespan( + USER_PROFILE_CACHE_CAPACITY, + PROFILE_REFRESH_TTL_SECONDS, + ), + endpoints: TimedSizedCache::with_size_and_lifespan( + USER_PROFILE_CACHE_CAPACITY, + PROFILE_REFRESH_TTL_SECONDS, + ), + } + } + + /// Whether a refresh is due for this identity, recording the attempt when it + /// is. Claiming and recording in one step under the caller's lock is what + /// keeps a burst of parallel requests from all fetching the same profile. + pub(crate) fn claim_refresh( + &mut self, + provider: &str, + subject: &str, + auth_time: Option, + ) -> bool { + let key = (provider.to_string(), subject.to_string()); + let due = match self.attempts.cache_get(&key) { + // Within the span of the last attempt: only evidence that the user + // authenticated again since then justifies asking the provider. + Some(covered) => match (auth_time, covered) { + (Some(now), Some(then)) => now > *then, + (Some(_), None) => true, + (None, _) => false, + }, + None => true, + }; + if due { + self.attempts.cache_set(key, auth_time); + } + due + } + + fn cached_endpoint(&mut self, issuer: &str) -> Option> { + self.endpoints.cache_get(&issuer.to_string()).cloned() + } + + fn remember_endpoint(&mut self, issuer: &str, endpoint: Option) { + self.endpoints.cache_set(issuer.to_string(), endpoint); + } +} + +impl Default for UserProfileCache { + fn default() -> Self { + Self::new() + } +} + +/// The issuer's UserInfo endpoint, from cache or a discovery fetch. +pub(crate) async fn resolve_userinfo_endpoint( + cache: &tokio::sync::Mutex, + issuer: &str, + destination: OidcDestination, + clients: &OidcClients, +) -> Result { + if let Some(cached) = cache.lock().await.cached_endpoint(issuer) { + return cached.ok_or(UserInfoError::NotOffered); + } + // Discovery runs without the cache lock held, so a slow issuer cannot + // serialize every login. + let endpoint = fetch_discovery_document(issuer, destination, clients) + .await + .map_err(UserInfoError::Request)? + .userinfo_endpoint; + cache + .lock() + .await + .remember_endpoint(issuer, endpoint.clone()); + endpoint.ok_or(UserInfoError::NotOffered) +} + +#[cfg(test)] +mod test { + use super::{ + UserInfoError, UserInfoResponse, UserProfileCache, fetch_user_profile, + resolve_userinfo_endpoint, + }; + use crate::db::types::user::UserProfile; + use crate::oidc::destination::TenantIssuerPolicy; + use crate::oidc::fetch::{OidcClients, OidcDestination}; + use tokio::sync::Mutex; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn parse(json: &str) -> UserProfile { + serde_json::from_str::(json) + .unwrap() + .into() + } + + /// A provider serving discovery, and UserInfo when `userinfo` is given. + /// `discovery_calls` bounds how often discovery may be fetched. + async fn provider(userinfo: Option, discovery_calls: u64) -> MockServer { + crate::ensure_default_crypto_provider(); + let server = MockServer::start().await; + let mut document = serde_json::json!({ + "issuer": server.uri(), + "jwks_uri": format!("{}/jwks", server.uri()), + }); + if let Some(body) = userinfo { + document["userinfo_endpoint"] = format!("{}/userinfo", server.uri()).into(); + Mock::given(method("GET")) + .and(path("/userinfo")) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .mount(&server) + .await; + } + Mock::given(method("GET")) + .and(path("/.well-known/openid-configuration")) + .respond_with(ResponseTemplate::new(200).set_body_json(document)) + .expect(discovery_calls) + .mount(&server) + .await; + server + } + + /// The endpoint is discovered once and reused, and the provider's answer + /// lands as a profile. + #[tokio::test] + async fn a_discovered_endpoint_answers_with_the_profile() { + let server = provider( + Some(serde_json::json!({ + "sub": "ada", + "email": "ada@example.com", + // The spelling AWS Cognito uses. + "email_verified": "true", + "name": "Ada Lovelace", + })), + 1, + ) + .await; + let cache = Mutex::new(UserProfileCache::new()); + let destination = OidcDestination::OperatorConfigured; + let clients = OidcClients::new(&[]).unwrap(); + + let endpoint = resolve_userinfo_endpoint(&cache, &server.uri(), destination, &clients) + .await + .unwrap(); + let profile = fetch_user_profile(&endpoint, "ada", "token", destination, &clients) + .await + .unwrap(); + assert_eq!(profile.email.as_deref(), Some("ada@example.com")); + assert!(profile.email_verified); + assert_eq!(profile.display_name.as_deref(), Some("Ada Lovelace")); + + // The second resolution is served from the cache; `expect(1)` on the + // discovery mock fails the test on drop if it is not. + assert_eq!( + resolve_userinfo_endpoint(&cache, &server.uri(), destination, &clients) + .await + .unwrap(), + endpoint + ); + } + + /// A plain-HTTP endpoint is refused before the token is attached, whoever + /// chose the issuer. `expect(0)` on the UserInfo mock is the proof: the + /// request must never be made. + #[tokio::test] + async fn a_plain_http_endpoint_never_receives_the_token() { + crate::ensure_default_crypto_provider(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/userinfo")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"sub": "ada"})), + ) + .expect(0) + .mount(&server) + .await; + let clients = OidcClients::new(&[]).unwrap(); + // The mock binds loopback, which is a permitted cleartext destination, + // so name a routable host to get the case that matters. + let endpoint = server.uri().replace("127.0.0.1", "idp.example.com"); + + for destination in [ + OidcDestination::OperatorConfigured, + OidcDestination::TenantRegistered(TenantIssuerPolicy::AllowInternal), + ] { + let refused = + fetch_user_profile(&endpoint, "ada", "token", destination, &clients).await; + assert!( + matches!(refused, Err(UserInfoError::Destination(_))), + "{destination:?} accepted {endpoint}" + ); + } + } + + /// An answer about another identity is refused, however well-formed. + #[tokio::test] + async fn a_profile_for_another_subject_is_refused() { + let server = provider( + Some(serde_json::json!({"sub": "someone-else", "email": "ada@example.com"})), + 1, + ) + .await; + let cache = Mutex::new(UserProfileCache::new()); + let destination = OidcDestination::OperatorConfigured; + let clients = OidcClients::new(&[]).unwrap(); + + let endpoint = resolve_userinfo_endpoint(&cache, &server.uri(), destination, &clients) + .await + .unwrap(); + assert!(matches!( + fetch_user_profile(&endpoint, "ada", "token", destination, &clients).await, + Err(UserInfoError::SubjectMismatch) + )); + } + + /// A provider offering no UserInfo endpoint says so once, then from cache, + /// so it is not rediscovered on every login. + #[tokio::test] + async fn a_provider_without_userinfo_is_asked_once() { + let server = provider(None, 1).await; + let cache = Mutex::new(UserProfileCache::new()); + let destination = OidcDestination::OperatorConfigured; + let clients = OidcClients::new(&[]).unwrap(); + + for _ in 0..3 { + assert!(matches!( + resolve_userinfo_endpoint(&cache, &server.uri(), destination, &clients).await, + Err(UserInfoError::NotOffered) + )); + } + } + + #[test] + fn email_verified_accepts_a_boolean_or_a_string() { + assert!(parse(r#"{"sub":"s","email":"a@b.c","email_verified":true}"#).email_verified); + assert!(!parse(r#"{"sub":"s","email":"a@b.c","email_verified":"false"}"#).email_verified); + } + + #[test] + fn an_absent_or_odd_email_verified_reads_as_unverified_without_losing_the_rest() { + for json in [ + r#"{"sub":"s","email":"a@b.c","name":"A B"}"#, + r#"{"sub":"s","email":"a@b.c","name":"A B","email_verified":7}"#, + r#"{"sub":"s","email":"a@b.c","name":"A B","email_verified":"yes"}"#, + ] { + let profile = parse(json); + assert!(!profile.email_verified, "{json}"); + assert_eq!(profile.email.as_deref(), Some("a@b.c"), "{json}"); + } + } + + #[test] + fn the_full_name_wins_over_the_provider_assigned_handle() { + let both = + parse(r#"{"sub":"s","name":"Ada Lovelace","preferred_username":"federated_ada"}"#); + assert_eq!(both.display_name.as_deref(), Some("Ada Lovelace")); + + let handle_only = parse(r#"{"sub":"s","preferred_username":"federated_ada"}"#); + assert_eq!(handle_only.display_name.as_deref(), Some("federated_ada")); + } + + #[test] + fn a_claimed_refresh_is_not_due_again_until_the_user_authenticates_anew() { + let mut cache = UserProfileCache::new(); + assert!(cache.claim_refresh("iss", "sub", Some(100))); + assert!(!cache.claim_refresh("iss", "sub", Some(100))); + assert!(cache.claim_refresh("iss", "sub", Some(200))); + assert!(!cache.claim_refresh("iss", "sub", Some(200))); + // A different identity is tracked on its own. + assert!(cache.claim_refresh("iss", "other", Some(100))); + } + + #[test] + fn without_auth_time_one_refresh_stands_for_the_whole_span() { + let mut cache = UserProfileCache::new(); + assert!(cache.claim_refresh("iss", "sub", None)); + assert!(!cache.claim_refresh("iss", "sub", None)); + // A token that does name an authentication time is new evidence. + assert!(cache.claim_refresh("iss", "sub", Some(1))); + } +} diff --git a/crates/pipeline-manager/src/runner/error.rs b/crates/pipeline-manager/src/runner/error.rs index 45f82d6033c..60045fa8f3c 100644 --- a/crates/pipeline-manager/src/runner/error.rs +++ b/crates/pipeline-manager/src/runner/error.rs @@ -1,8 +1,6 @@ use crate::db::types::resources_status::{ResourcesDesiredStatus, ResourcesStatus}; use crate::db::types::utils::ValidationError; -use actix_web::{ - HttpResponse, HttpResponseBuilder, ResponseError, body::BoxBody, http::StatusCode, -}; +use actix_web::{HttpResponse, ResponseError, body::BoxBody, http::StatusCode}; use feldera_types::error::{DetailedError, ErrorResponse}; use indoc::writedoc; use serde::Serialize; @@ -469,6 +467,6 @@ impl ResponseError for RunnerError { } fn error_response(&self) -> HttpResponse { - HttpResponseBuilder::new(self.status_code()).json(ErrorResponse::from_error(self)) + crate::error::json_error_response(self) } } diff --git a/docs.feldera.com/docs/changelog.md b/docs.feldera.com/docs/changelog.md index 87ce3c6dfbd..e84495fd983 100644 --- a/docs.feldera.com/docs/changelog.md +++ b/docs.feldera.com/docs/changelog.md @@ -14,6 +14,56 @@ import TabItem from '@theme/TabItem'; ## Unreleased + - Feldera's membership table now authorizes every login: a user acts + in the tenants they hold a membership in, whether or not the token's + `tenants` claim names them. The claim, the issuer tenant, and the + per-`sub` personal tenant become provisioning strategies that create + memberships at login, gated by the new + `authorization.provisionOnLogin` (default `true`). Set it to `false` + so that access comes only from memberships granted through + `POST /v0/tenant/users` and the web console, with no claim mapping + maintained at the identity provider. See + [Tenant Assignment Strategies](/get-started/enterprise/authentication#tenant-assignment-strategies) + and the + [migration guide](/get-started/enterprise/authentication#migrating-to-feldera-managed-memberships). + + - Breaking change (revocation): narrowing a token's `tenants` claim no + longer revokes access, because membership rows from past logins stay + live. While `provisionOnLogin` is `true`, removing a member does not + keep them out either: the claim re-enrolls them on their next login, + so full revocation takes both levers. Audit memberships per tenant + with `GET /v0/tenant/users` before or right after upgrading, and see + [Revoking access](/get-started/enterprise/authentication/roles#revoking-access). + Managed tenancy (the `tenants` claim) is deprecated in favor of + Feldera-managed memberships. Two smaller visible changes: some + login-path refusals answer `403` or `400` where they answered `401`, + and a claim entry a multi-tenant user does not select no longer + creates a missing tenant at login (create tenants explicitly with + `POST /v0/tenants`). + + - Member lists now carry the name and email the identity provider + holds for each member, and whether that provider vouches for the + email, so an administrator recognizes a member without decoding an + OIDC `sub`. `GET /v0/tenant/users` gains `display_name` and + `email_verified`; the web console's member list and `fda member list` + show both. + + - Renaming a tenant no longer requires updating identity provider + claim mappings once `provisionOnLogin` is `false`: memberships + reference the tenant by id, not by name. + + - Owners can retrieve a single tenant by name or identifier through + `GET /v0/tenants/{tenant_id}` or `fda tenant get`, so provisioning + automation such as an operator reconcile loop checks for a tenant + with one request instead of filtering `GET /v0/tenants`. See + [Changing your authentication setup](/get-started/enterprise/authentication#changing-your-authentication-setup). + + - `POST /v0/tenants` is now idempotent: creating a name that already + exists returns the existing tenant with `200 OK` instead of failing + with `409 Conflict`, and a fresh name still returns `201 Created`. + Both responses carry the tenant's `id`, `name`, and + `initial_provider`. `fda tenant create` is the CLI counterpart. + - Input connectors support the `soft_delete` property, which ingests deletions as insertions and reports the original polarity of each record in the `is_delete` metadata attribute, so that a table @@ -37,11 +87,7 @@ import TabItem from '@theme/TabItem'; stay 0 or 1. SQL pipelines are not affected: the SQL compiler never generated input sets. - - The SQL compiler was incorrectly garbage-collecting input - tables with a primary key and a column with LATENESS (#6690). Such - tables can only be GC-ed if the column with LATENESS is part of - the primary key. As a result some programs that used to run - with finite state will now have unbounded state. + ## v0.327.0 - Role-based access control (RBAC). Access is now governed by per-user, per-tenant roles (`read` < `write` < `admin` < `owner`) rather than every @@ -91,6 +137,14 @@ import TabItem from '@theme/TabItem'; behavior. `fda apikey create` defaults to `--role read` for the same reason; pass `--role write` where a key needs to make changes. + ## v0.325.0 + + - The SQL compiler was incorrectly garbage-collecting input + tables with a primary key and a column with LATENESS (#6690). Such + tables can only be GC-ed if the column with LATENESS is part of + the primary key. As a result some programs that used to run + with finite state will now have unbounded state. + ## v0.322.0 - Pipeline API field `deployment_runtime_status_details` is now strongly typed, diff --git a/docs.feldera.com/docs/get-started/enterprise/authentication/index.mdx b/docs.feldera.com/docs/get-started/enterprise/authentication/index.mdx index 7a9cc4cfa37..b372dec6b63 100644 --- a/docs.feldera.com/docs/get-started/enterprise/authentication/index.mdx +++ b/docs.feldera.com/docs/get-started/enterprise/authentication/index.mdx @@ -17,9 +17,9 @@ Feldera API supports two authentication types: **Feldera Tenant** is a scope of access to Feldera pipelines for their management and configuration. The same tenant can be assigned to multiple users for shared access. The ingress and egress business data flow, which is configured through connectors, has it's own connector-dependent authentication and is not subject to tenant-based authorization. Interaction with Feldera is always authorized through a tenant, so access to the platform implies assigning tenant to the user. -For OIDC authentication, the tenant is derived from claims in the OIDC Access token. +For OIDC authentication, the tenants a user may act in are stored by Feldera; a tenancy strategy can provision memberships automatically at login from the OIDC Access token. For API key authentication, the key is associated with the tenant through which the key was generated. -We support multiple authorization use-cases through strategies to assign tenant to Feldera API and clients' users. +We support multiple authorization use-cases through strategies to provision tenants and memberships for Feldera API and clients' users. As an orthogonal feature, the authorized-groups startup parameter can be used to limit access to the users who are a member of at least one of the groups in this list. The membership is determined based on the `groups` claim of an OIDC Access token. Users must belong to at least one of the specified groups to access Feldera. If `authorizedGroups` is not specified or empty, no group restrictions apply. @@ -28,7 +28,11 @@ This group check applies to every principal, including platform owners: the memb ## Tenant Assignment Strategies -Feldera provides three different tenant assignment strategies to support different deployment patterns: +A user acts in the tenants they hold a membership in, at the role the +membership records. Memberships are granted through the +[member-management API](/api/provision-tenant-member) and web console, and, by +default (`authorization.provisionOnLogin: true`), also provisioned at login by +one of three strategies: ### Individual Tenancy (Enabled by default) @@ -37,15 +41,41 @@ Each authenticated user gets their own private tenant based on the `sub` claim o ### Organization-wide Tenancy Users from the same organization share a tenant, derived from the issuer hostname of the authentication token. Does not require authentication provider configuration. Configured with `authorization.issuerTenant` Helm value. -### Managed Tenancy -Multiple teams can use the same Feldera instance with complete tenant isolation. Each team's users should be assigned to corresponding tenant(s) with the proper configuration of the dynamic `tenants` claim in the OIDC Access token. - -The `tenants` claim authorizes the user to access any of the specified tenants. It is always respected if issued. -`tenants` can contain either a list, or a string of comma-separated tenant names. - -The user can only interact with the API through a single tenant at a time. When the user is authorized to multiple tenants, in Web Console they can switch between the current tenant. -For HTTP API use, the current tenant name is specified in the `Feldera-Tenant` header. +### Managed Tenancy (deprecated) + +Each team's users are assigned to their tenant(s) through the dynamic `tenants` +claim in the OIDC Access token. `tenants` can contain either a list, or a +string of comma-separated tenant names; empty entries are ignored. + +The claim provisions rather than restricts: the deliberately selected tenant is +created if absent and the user enrolled, further listed names enroll the user +into tenants that already exist, and the user may additionally act in any +tenant they hold a membership in, whether the claim names it or not. Narrowing +the claim therefore does not revoke access; remove the membership as well (see +[Revoking access](/get-started/enterprise/authentication/roles#revoking-access)). + +This strategy is deprecated in favor of managing memberships in Feldera +directly, which needs no claim mapping at the identity provider; see +[Migrating to Feldera-managed memberships](#migrating-to-feldera-managed-memberships). +One consequence for new multi-entry-claim users: a listed tenant that does not +exist yet is not created by their login unless they select it with the +`Feldera-Tenant` header, so create tenants up front with +[`POST /v0/tenants`](/api/create-tenant). + +### Turning login provisioning off + +`authorization.provisionOnLogin: false` (default `true`) disables all three +strategies at once: a login creates no tenant and enrolls no one, and access +comes only from memberships granted through the API and console. This is the +recommended end state for enterprise deployments, because access then has one +audit trail and one revocation lever. It requires a configured +`authorization.owners` or `authorization.ownerTrusts`; the manager refuses to +start otherwise, because without an owner no principal could ever grant access. + +The user interacts with the API through a single tenant at a time. A user who +belongs to several tenants switches tenants in the Web Console, or names one in +the `Feldera-Tenant` header on HTTP API calls. ## Tenant Assignment use cases @@ -252,7 +282,9 @@ expect to change providers regularly**. Where the name does change, the old tenant keeps every pipeline and is no longer reachable by ordinary logins. Recover it the same way as above: an owner selects -it with `Feldera-Tenant` (by name or UUID, and `GET /v0/tenants` lists both) and +it with `Feldera-Tenant` (by name or UUID; [`GET /v0/tenants`](/api/list-tenants) +lists both, and [`GET /v0/tenants/{tenant_id}`](/api/get-tenant) retrieves one by +either selector) and moves what is needed, or gives it the name the new provider produces, with `displace_existing`, so the logins land back on it. @@ -278,6 +310,50 @@ someone can do the granting. See [Roles](/get-started/enterprise/authentication/roles). ::: +### Migrating to Feldera-managed memberships + +A deployment using the deprecated managed tenancy (`tenants` claim) moves to +Feldera-managed memberships as follows: + +1. Upgrade Feldera. `provisionOnLogin` stays `true`; nothing changes at the + identity provider yet. +2. Audit existing memberships per tenant with + [`GET /v0/tenant/users`](/api/list-tenant-members): every past login left a + membership row, including for users whose claim no longer names the tenant. + Remove rows that should not confer access, because they do now. +3. Populate the memberships you want: let every user log in once, or + pre-provision them with + [`POST /v0/tenant/users`](/api/provision-tenant-member). +4. Verify per tenant that the member list matches the intended access. +5. Confirm `authorization.owners` (or `ownerTrusts`) is set; the manager + refuses to start without one in the next step. +6. Set `authorization.provisionOnLogin: false` and restart. +7. Delete the `tenants` claim mapping at the identity provider. + +Deployments on individual or organization-wide tenancy need no migration: +behavior is unchanged until an admin grants cross-tenant memberships, and +`provisionOnLogin: false` is available whenever explicit provisioning is +preferred. + +### How members are identified in the member list + +A membership is keyed to an OIDC subject, which is rarely a string anyone +recognizes. So that an administrator can tell members apart, +[`GET /v0/tenant/users`](/api/list-tenant-members), the web console's member +list, and `fda member list` also report the name and email the identity +provider holds: + +| Field | Source | +|---|---| +| `subject` | the token's `sub`; the identity itself | +| `display_name` | the provider's `name`, or `preferred_username` | +| `email` | the provider's `email`, or an email supplied when pre-provisioning | +| `email_verified` | whether the provider vouches for `email` | + +Feldera reads the name and email from the provider's +[OIDC UserInfo endpoint](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo), +because an access token often carries neither. + ## Configuration options ### Mapping Pipeline Manager Options to Helm Chart Values @@ -334,6 +410,11 @@ authorization: # Role for the user whose login first creates a tenant (default: "admin") # Set to "write"/"read" for a shared sandbox; see the Roles page. firstUserRole: "admin" # Maps to: FELDERA_AUTH_FIRST_USER_ROLE + + # Provision tenants and memberships at login per the tenancy strategy + # (default: true). false = access comes only from memberships granted + # through the API and console; requires owners or ownerTrusts. + provisionOnLogin: true # Maps to: FELDERA_AUTH_PROVISION_ON_LOGIN ``` ### Environment Variables Reference @@ -361,21 +442,35 @@ FELDERA_OWNER_TRUSTS='[{"issuer":"https://token.actions.githubusercontent.com"," # JSON array; default: [] FELDERA_AUTH_DEFAULT_ROLE=read # read or write; default: read FELDERA_AUTH_FIRST_USER_ROLE=admin # read, write, or admin; default: admin +FELDERA_AUTH_PROVISION_ON_LOGIN=true # default: true; false requires owners # AWS Cognito specific AWS_COGNITO_LOGIN_URL=https://... AWS_COGNITO_LOGOUT_URL=https://... ``` -## Tenant Resolution Priority - -Feldera resolves tenant assignment using the following priority order: - -1. **`tenants` claim** - Explicit tenant assignment via OIDC provider -2. **Issuer domain `iss` claim** (when `issuerTenant: true`) -3. **User `sub` claim** (when `individualTenant: true`) - -If no valid tenant is found and `individualTenant: false` the user will be denied authorization. +## Tenant Resolution + +A login's acting tenant comes from the user's memberships: + +1. A `Feldera-Tenant` header (tenant name or UUID) selects the tenant; it must + be one the user holds a membership in. An unknown tenant and one the user + is no member of are answered alike, so the header cannot be used to probe + which tenants exist. +2. Without a header, a user with exactly one membership acts in it. +3. Without a header, a user with several memberships is refused; the + [session endpoint](/api/get-session) still answers, listing the memberships + so a client can pick one (the Web Console shows a tenant picker). +4. A user with no membership at all is denied, again except for the session + endpoint, which answers with an empty membership list so a client can show + a useful message. + +Before this resolution, and only while `provisionOnLogin` is `true` (the +default), the login provisions memberships in priority order: the `tenants` +claim, the issuer domain (when `issuerTenant: true`), or the user's `sub` +(when `individualTenant: true`). With all three strategies off, or with +`provisionOnLogin: false`, a login provisions nothing and case 4 applies until +an admin grants a membership. ## Provider-Specific Setup diff --git a/docs.feldera.com/docs/get-started/enterprise/authentication/okta-sso.md b/docs.feldera.com/docs/get-started/enterprise/authentication/okta-sso.md index de5e992e3b8..3971e11a172 100644 --- a/docs.feldera.com/docs/get-started/enterprise/authentication/okta-sso.md +++ b/docs.feldera.com/docs/get-started/enterprise/authentication/okta-sso.md @@ -82,7 +82,13 @@ You can take advantage of the supported authorization models by properly configu ## Tenant Assignment with custom claims -Feldera supports multiple authorization use-cases through [managed tenancy](index.mdx#Managed%20Tenancy). You can choose between the supported tenant claims to implement the appropriate authorization scenario. Navigate to the **Claims** tab in the Custom Authorization Server to configure one of: +Tenant assignment through custom claims is deprecated in favor of +[Feldera-managed memberships](index.mdx#migrating-to-feldera-managed-memberships), +which need no claim mapping and no Custom Authorization Server. For a +deployment staying on [managed tenancy](index.mdx#managed-tenancy-deprecated), +choose between the supported tenant claims to implement the appropriate +authorization scenario. Navigate to the **Claims** tab in the Custom +Authorization Server to configure one of: ### `tenants` claim diff --git a/docs.feldera.com/docs/get-started/enterprise/authentication/roles.md b/docs.feldera.com/docs/get-started/enterprise/authentication/roles.md index c13608a1830..e0122e4a82b 100644 --- a/docs.feldera.com/docs/get-started/enterprise/authentication/roles.md +++ b/docs.feldera.com/docs/get-started/enterprise/authentication/roles.md @@ -30,9 +30,12 @@ platform-wide and comes only from deploy-time configuration, as a user ## How a role is assigned -- On a user's first login to a tenant, if the user has no membership yet, they - are admitted at the configured default role (see [below](#default-roles)) - and a membership record is created. +- On a user's first login to a tenant that the tenancy strategy resolves, if + the user has no membership yet, they are admitted at the configured default role + (see [below](#default-roles)) and a membership record is created. This + login-time enrollment only happens while `authorization.provisionOnLogin` is + `true` (the default); with it off, access comes solely from memberships + granted through the API and console, and a user without one is denied. - When that first login also creates the tenant (auto-provisioning, because the resolved tenant did not exist yet), the user is granted the configured first-user role, `admin` by default (see [below](#default-roles)). A @@ -50,6 +53,27 @@ platform-wide and comes only from deploy-time configuration, as a user - An API key carries a role capped at its creator's role, limited to `read` or `write`. +## Revoking access + +Removing a member ([`DELETE /v0/tenant/users/{user_id}`](/api/remove-tenant-member), +or the Admin page) deletes their membership. Whether that alone revokes access depends on +`authorization.provisionOnLogin`: + +- With provisioning on (the default), the user's next login re-enrolls them at + the default role whenever the tenancy strategy still resolves the tenant: + their `tenants` claim still names it, or it is their personal or issuer + tenant. Full revocation then takes both levers: remove the membership in + Feldera and stop the strategy from re-provisioning it (adjust the claim at + the identity provider). Deassigning the user from the application at the + provider always revokes, because no token is issued at all. +- With provisioning off, nothing re-enrolls: removing the membership is + revocation, effective on the user's next request. + +Removal does not touch what the member created: API keys and OIDC trust +relationships are tenant resources and keep working, and a role demotion +demotes neither. Review API keys and trusts separately when members depart +and revoke them if necessary. + ## Platform owners A new installation has no owners until you configure them. @@ -75,6 +99,12 @@ authorization: Prefer the subject or provider-qualified form over email. An email entry matches only when the identity provider marks the email verified, and an email address is user-facing and can change, whereas the subject is stable. + +Owner matching reads the access token itself, never the provider's UserInfo +endpoint, so that who is an owner is settled by the token in hand. Many +providers put neither `email` nor `email_verified` in an access token, and an +email entry cannot match on such a provider. If an owner entry appears to have +no effect, check the token's own claims and use the subject form instead. ::: Set `authorization.ownerTrusts` (environment: `FELDERA_OWNER_TRUSTS`), a list of diff --git a/js-packages/web-console/AUTH_AND_TENANCY.md b/js-packages/web-console/AUTH_AND_TENANCY.md index aa3841977eb..99acc9ef3d5 100644 --- a/js-packages/web-console/AUTH_AND_TENANCY.md +++ b/js-packages/web-console/AUTH_AND_TENANCY.md @@ -27,17 +27,19 @@ Distinct role subsets exist for different principals: The role comes from the session payload, not the JWT. `GET /v0/config/session` returns `SessionInfo`: ``` -SessionInfo { tenant_id, tenant_name, role } // role: read | write | admin | owner +SessionInfo { tenant_id, tenant_name, role, memberships } // role: read|write|admin|owner ``` -`src/routes/+layout.ts` reads it into `page.data.feldera`: +`tenant_id`, `tenant_name` and `role` are null together, exactly when the login +resolved no acting tenant. `src/routes/+layout.ts` diverts that case into +`unresolvedTenant` and never builds `feldera` from it; otherwise it reads: -| `page.data.feldera` field | Source | Use | -| ------------------------- | ------------------------------- | ------------------------------- | -| `role` | `roleOf(sessionConfig.role)` | normalized role, default `read` | -| `permissions` | `permissionsOf(role)` | the list UI gates read | -| `tenantId`, `tenantName` | `sessionConfig.tenant_id/_name` | current acting tenant | -| `authorizedTenants?` | JWT `tenants` claim (decoded) | multi-tenant switch list | +| `page.data.feldera` field | Source | Use | +| ------------------------- | ------------------------------- | --------------------------------------------------------- | +| `role` | `roleOf(sessionConfig.role)` | normalized role, or `NO_ROLE` when the session named none | +| `permissions` | `permissionsOf(role)` | the list UI gates read; empty for `NO_ROLE` | +| `tenantId`, `tenantName` | `sessionConfig.tenant_id/_name` | current acting tenant | +| `memberships` | `sessionConfig.memberships` | tenants this login may switch to | `role` is the whole permission surface the backend sends: one ordered role, `read < write < admin < owner`, no separate capability list or resource-level @@ -46,9 +48,11 @@ materializes `permissions` from the role via the client role to permission map (see `web-console-permissions.md`), shaped as if the server had sent the list, so each UI gate names the permission it needs and reads `permissions` for it. -`authorizedTenants` is the only value read from the token: the `tenants` claim -(array, or comma-separated string) lists tenants a multi-tenant login may act in. -An owner's token typically carries no `tenants` claim. +A role is granted per membership and carries no meaning outside a tenant, so the +server omits it when none resolves, `roleOf` reports `NO_ROLE`, `permissions` +comes out empty, and every `` gate closes on its own. Nothing is read from the token: `memberships` +comes from the membership table by way of the session payload, which is also what +the tenant picker offers. ## Tenancy and the acting tenant @@ -124,7 +128,7 @@ where `*` matches any run of characters. A match grants the trust's `role`. ## Admin dashboard -Route `/admin` (`src/routes/(system)/(authenticated)/admin/+page.ts`). Gated in +Route `/admin` (`src/routes/(system)/(authenticated)/(authorized)/admin/+page.ts`). Gated in `load`: entry requires `role` of `admin` or `owner`; anyone else is redirected home. `AdminPage.svelte` composes: diff --git a/js-packages/web-console/src/lib/components/admin/AdminPage.svelte b/js-packages/web-console/src/lib/components/admin/AdminPage.svelte index fc7b59564a3..ca461d871ad 100644 --- a/js-packages/web-console/src/lib/components/admin/AdminPage.svelte +++ b/js-packages/web-console/src/lib/components/admin/AdminPage.svelte @@ -16,13 +16,14 @@ // Owner-only: pick a tenant (by UUID) to inspect its members in place, without // changing the global acting-tenant. Empty string means the current tenant. - let adminTenant = $state(page.data.feldera?.tenantId ?? '') + let adminTenant = $state(page.data.feldera!.tenantId) const selectedTenant = $derived(adminTenant || undefined) const tenants = asyncReadable([], getTenants, { reloadable: true }) + // Before the tenant list arrives, name the acting tenant from the session. It + // is unnamed only when the session payload is absent (no-auth deployments). const tenantLabel = $derived( $tenants.find((t) => t.id === adminTenant)?.name ?? - page.data.feldera?.tenantName ?? - 'current tenant' + (page.data.feldera!.tenantName || 'current tenant') ) // Tenants the header picker can switch to: every tenant except the one whose // members already show below, since the picker's job is to pick a different one. @@ -69,7 +70,7 @@ {/snippet} {#snippet usersTitle()} - Users & roles for {#if manageTenants.allowed} + Users & roles for {#if manageTenants.allowed && tenantCollection.items.length > 0} @@ -147,7 +148,7 @@ )} {#snippet tenantsBody()} - + {/snippet} {@render section('Tenants', 'Owner-only: list and create tenants.', tenantsBody)} {/if} diff --git a/js-packages/web-console/src/lib/components/admin/AdminPage.svelte.spec.ts b/js-packages/web-console/src/lib/components/admin/AdminPage.svelte.spec.ts index 8616a900b7c..e1fc23e29f8 100644 --- a/js-packages/web-console/src/lib/components/admin/AdminPage.svelte.spec.ts +++ b/js-packages/web-console/src/lib/components/admin/AdminPage.svelte.spec.ts @@ -3,7 +3,9 @@ * "Users & roles" header lets an owner switch which tenant's members show * below; the trigger (the tenant name plus a "select a different tenant" hint) * is owner-only (`write:tenant`). A non-owner sees the tenant name as plain - * text. The picker omits the tenant already shown and closes on selection. + * text; an owner with no other tenant sees the trigger inert, so an empty list + * cannot open. The picker omits the tenant already shown and closes on + * selection. * * The child tables (UserRoleTable, TenantList) are stubbed out: they mount * monaco-backed dialogs and fetch on mount, none of which the header gate @@ -59,6 +61,10 @@ const headingText = () => const usersHeading = () => headingText().find((t) => t.includes('Users & roles')) ?? '' const optionLabels = () => Array.from(document.querySelectorAll('[role="option"]')).map((e) => e.textContent?.trim() ?? '') +const triggerButton = () => + Array.from(document.querySelectorAll('h2 button')).find((b) => + b.textContent?.includes(current.name) + ) describe('AdminPage — write:tenant header picker', () => { afterEach(async () => { @@ -73,10 +79,17 @@ describe('AdminPage — write:tenant header picker', () => { vi.clearAllMocks() }) - it('offers the tenant picker to an owner', () => { + it('offers the tenant picker to an owner with another tenant to switch to', async () => { + current.id = 't-acme' + current.name = 'acme' + tenantsState.list = [ + { id: 't-acme', name: 'acme' }, + { id: 't-beta', name: 'beta' } + ] mountPage('owner') - expect(usersHeading()).toContain('acme-tenant') - expect(usersHeading()).toContain('select a different tenant') + await expect.poll(usersHeading).toContain('acme') + await expect.poll(usersHeading).toContain('select a different tenant') + await expect.poll(() => triggerButton()?.disabled).toBe(false) }) it('shows the tenant name as plain text to a non-owner admin', () => { @@ -85,6 +98,20 @@ describe('AdminPage — write:tenant header picker', () => { // hint here and fails this test. expect(usersHeading()).toContain('acme-tenant') expect(usersHeading()).not.toContain('select a different tenant') + expect(triggerButton()).toBeUndefined() + }) + + it('shows the tenant name as plain text to an owner of a single tenant', async () => { + current.id = 't-acme' + current.name = 'acme' + tenantsState.list = [{ id: 't-acme', name: 'acme' }] + mountPage('owner') + await expect.poll(usersHeading).toContain('acme') + // Nothing to switch to, so no trigger and no way to open an empty list. + // Dropping `canSwitchTenant` from the gate surfaces the hint and fails here. + expect(usersHeading()).not.toContain('select a different tenant') + expect(triggerButton()).toBeUndefined() + expect(optionLabels()).toEqual([]) }) it('lists the other tenants and omits the one already shown', async () => { diff --git a/js-packages/web-console/src/lib/components/admin/AdminPage.tenantSync.svelte.spec.ts b/js-packages/web-console/src/lib/components/admin/AdminPage.tenantSync.svelte.spec.ts new file mode 100644 index 00000000000..cbbd1e4785c --- /dev/null +++ b/js-packages/web-console/src/lib/components/admin/AdminPage.tenantSync.svelte.spec.ts @@ -0,0 +1,89 @@ +/** + * The admin page's tenant store is shared with the TenantList it renders, so a + * tenant created (or deleted) in the list reaches the header's picker without a + * reload. This mounts the real TenantList, unlike AdminPage.svelte.spec.ts, + * which stubs it to test the header gate alone. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { page } from 'vitest/browser' +import { render } from 'vitest-browser-svelte' +import { permissionsOf } from '$lib/services/rbac' + +const tenantsState = vi.hoisted(() => ({ + list: [{ id: 't-acme', name: 'acme', initial_provider: 'oidc' }] as any[] +})) + +vi.mock('$app/state', () => ({ + page: { + data: { + get feldera() { + return { + role: 'owner', + permissions: permissionsOf('owner'), + tenantId: 't-acme', + tenantName: 'acme' + } + } + } + } +})) +vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn(async () => {}) })) +vi.mock('$lib/compositions/configCache', () => ({ clearConfigCaches: vi.fn() })) +vi.mock('$lib/services/auth', () => ({ setSelectedTenant: vi.fn() })) +// Only the members table is stubbed: it fetches on mount and has no part in +// what the picker offers. +vi.mock('$lib/components/admin/UserRoleTable.svelte', () => ({ default: () => {} })) +vi.mock('$lib/services/pipelineManager', () => ({ + getTenants: vi.fn(async () => [...tenantsState.list]), + getAuthConfig: vi.fn(async () => undefined), + getConfiguredOwners: vi.fn(async () => undefined), + createTenant: vi.fn(async (name: string) => { + tenantsState.list.push({ id: `t-${name}`, name, initial_provider: 'oidc' }) + }), + deleteTenant: vi.fn(async (id: string) => { + tenantsState.list = tenantsState.list.filter((t) => t.id !== id) + }), + renameTenant: vi.fn(async () => ({})) +})) + +// Imported AFTER vi.mock so the mocks take effect. +import AdminPage from './AdminPage.svelte' + +let mounted: { unmount: () => Promise } | undefined +let mountTarget: HTMLDivElement | undefined + +const headingText = () => + Array.from(document.querySelectorAll('h2')).map((h) => h.textContent ?? '') +const usersHeading = () => headingText().find((t) => t.includes('Users & roles')) ?? '' +const optionLabels = () => + Array.from(document.querySelectorAll('[role="option"]')).map((e) => e.textContent?.trim() ?? '') + +describe('AdminPage — tenant list and picker share one store', () => { + afterEach(async () => { + await mounted?.unmount() + mounted = undefined + mountTarget?.remove() + mountTarget = undefined + tenantsState.list = [{ id: 't-acme', name: 'acme', initial_provider: 'oidc' }] + vi.clearAllMocks() + }) + + it('offers a newly created tenant in the picker without a reload', async () => { + mountTarget = document.createElement('div') + document.body.appendChild(mountTarget) + mounted = render(AdminPage, { target: mountTarget }) as any + + // Only the acting tenant exists, so there is nothing to switch to yet. + await expect.poll(() => document.body.textContent).toContain('t-acme') + expect(usersHeading()).not.toContain('select a different tenant') + + await page.getByPlaceholder('acme-prod').fill('beta') + await page.getByRole('button', { name: 'Create' }).click() + + // With a store per component the picker never learns about 'beta' and this + // is where the test fails. + await expect.poll(usersHeading).toContain('select a different tenant') + await page.getByText('select a different tenant').click() + await expect.poll(optionLabels).toEqual(['beta']) + }) +}) diff --git a/js-packages/web-console/src/lib/components/admin/TenantList.svelte b/js-packages/web-console/src/lib/components/admin/TenantList.svelte index 752a3310784..06cad261b62 100644 --- a/js-packages/web-console/src/lib/components/admin/TenantList.svelte +++ b/js-packages/web-console/src/lib/components/admin/TenantList.svelte @@ -1,5 +1,5 @@ -{#if page.data.feldera} + +{#if feldera && (memberships.length > 1 || feldera.tenantName)} {/if} diff --git a/js-packages/web-console/src/lib/components/auth/CurrentTenant.svelte.spec.ts b/js-packages/web-console/src/lib/components/auth/CurrentTenant.svelte.spec.ts new file mode 100644 index 00000000000..703857ca215 --- /dev/null +++ b/js-packages/web-console/src/lib/components/auth/CurrentTenant.svelte.spec.ts @@ -0,0 +1,137 @@ +// CurrentTenant drives from the session's membership list, plus the acting +// tenant when that lies outside the list: the acting tenant name always +// renders, the dropdown affordance appears only when there is more than one +// tenant to act in, and switching selects by tenant id. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { render } from 'vitest-browser-svelte' + +const felderaState = vi.hoisted(() => ({ + current: undefined as + | { + tenantId: string + tenantName: string + role: string + memberships: { tenantId: string; name: string; role: string }[] + } + | undefined +})) +const switchTenant = vi.hoisted(() => vi.fn()) + +vi.mock('$app/state', () => ({ + page: { + data: { + get feldera() { + return felderaState.current + } + } + } +})) +vi.mock('$lib/compositions/switchTenant', () => ({ + switchTenant: (...args: unknown[]) => switchTenant(...args) +})) + +// Imported AFTER vi.mock so the mocks take effect. +import CurrentTenant from './CurrentTenant.svelte' + +const selectElement = () => document.querySelector('select') + +describe('CurrentTenant', () => { + afterEach(() => { + felderaState.current = undefined + switchTenant.mockReset() + }) + + it('renders nothing when there is no session data', async () => { + await render(CurrentTenant) + expect(selectElement()).toBeNull() + expect(document.body.textContent).not.toContain('Tenant') + }) + + it('renders nothing when there is no tenant name and no choice to make', async () => { + felderaState.current = { tenantId: '', tenantName: '', role: 'read', memberships: [] } + await render(CurrentTenant) + expect(document.body.textContent).not.toContain('Tenant') + }) + + it('shows the plain tenant name, not a dropdown, for a single membership', async () => { + felderaState.current = { + tenantId: 't-acme', + tenantName: 'acme', + role: 'admin', + memberships: [{ tenantId: 't-acme', name: 'acme', role: 'admin' }] + } + await render(CurrentTenant) + expect(selectElement()).toBeNull() + expect(document.body.textContent).toContain('acme') + }) + + it('shows the plain acting tenant name for an owner with no memberships', async () => { + felderaState.current = { + tenantId: 't-x', + tenantName: 'acted-as', + role: 'owner', + memberships: [] + } + await render(CurrentTenant) + expect(selectElement()).toBeNull() + expect(document.body.textContent).toContain('acted-as') + }) + + it('offers every membership and switches by tenant id on change', async () => { + felderaState.current = { + tenantId: 't-acme', + tenantName: 'acme', + role: 'admin', + memberships: [ + { tenantId: 't-acme', name: 'acme', role: 'admin' }, + { tenantId: 't-beta', name: 'beta', role: 'read' } + ] + } + await render(CurrentTenant) + const select = selectElement()! + expect(select.value).toBe('t-acme') + expect(Array.from(select.options).map((o) => o.value)).toEqual(['t-acme', 't-beta']) + select.value = 't-beta' + select.dispatchEvent(new Event('change', { bubbles: true })) + await expect.poll(() => switchTenant.mock.calls).toEqual([['t-beta']]) + }) + + it('displays an acting tenant outside the membership list (owner act-as)', async () => { + felderaState.current = { + tenantId: 't-elsewhere', + tenantName: 'elsewhere', + role: 'owner', + memberships: [ + { tenantId: 't-acme', name: 'acme', role: 'admin' }, + { tenantId: 't-beta', name: 'beta', role: 'read' } + ] + } + await render(CurrentTenant) + const select = selectElement()! + // The acting tenant heads the options so the select displays its name even + // though it is no membership. + expect(select.value).toBe('t-elsewhere') + expect(Array.from(select.options).map((o) => o.textContent)).toEqual([ + 'elsewhere', + 'acme', + 'beta' + ]) + }) + + it('offers a way back when acting outside a lone membership', async () => { + felderaState.current = { + tenantId: 't-elsewhere', + tenantName: 'elsewhere', + role: 'owner', + memberships: [{ tenantId: 't-acme', name: 'acme', role: 'admin' }] + } + await render(CurrentTenant) + const select = selectElement()! + expect(select.value).toBe('t-elsewhere') + expect(Array.from(select.options).map((o) => o.textContent)).toEqual(['elsewhere', 'acme']) + select.value = 't-acme' + select.dispatchEvent(new Event('change', { bubbles: true })) + await expect.poll(() => switchTenant.mock.calls).toEqual([['t-acme']]) + }) +}) diff --git a/js-packages/web-console/src/lib/components/auth/ProfileButton.svelte b/js-packages/web-console/src/lib/components/auth/ProfileButton.svelte index e3514ee1eee..5f476a33350 100644 --- a/js-packages/web-console/src/lib/components/auth/ProfileButton.svelte +++ b/js-packages/web-console/src/lib/components/auth/ProfileButton.svelte @@ -85,6 +85,15 @@ {#snippet adminIcon()}
{/snippet} + {#snippet healthIcon()} +
+ {/snippet} {#if typeof auth === 'object' && 'logout' in auth} @@ -137,17 +146,10 @@ Sign Out {/if} -
- {#snippet healthIcon()} -
- {/snippet} - {@render profileItemButton('Feldera Health', healthIcon, { href: resolve('/health') })} + +
+ {@render profileItemButton('Feldera Health', healthIcon, { href: resolve('/health') })} +
diff --git a/js-packages/web-console/src/lib/components/auth/ProfileButton.svelte.spec.ts b/js-packages/web-console/src/lib/components/auth/ProfileButton.svelte.spec.ts new file mode 100644 index 00000000000..9cc441606d4 --- /dev/null +++ b/js-packages/web-console/src/lib/components/auth/ProfileButton.svelte.spec.ts @@ -0,0 +1,94 @@ +/** + * The profile menu is the one piece of chrome that renders on both sides of the + * `(authorized)` gate, so its tenant-scoped entries have to hide themselves when + * no tenant is resolved. Every one of them does that through ``, including + * the read-role cluster health entry: a session without an acting tenant holds no + * permissions at all, so no gate needs to ask about tenants directly. + * + * Fixtures derive `role` and `permissions` from a raw session role string the way + * `+layout.ts` does, so relaxing that rule fails these tests rather than silently + * restoring a menu of dead links. + */ +import { describe, expect, it, vi } from 'vitest' +import { render } from 'vitest-browser-svelte' +import { permissionsOf, roleOf } from '$lib/services/rbac' + +// Hoisted so the `$app/state` factory below can close over it: each test sets +// `state.data` to the session shape it is about. +const state = vi.hoisted(() => ({ + url: new URL('http://localhost/'), + data: {} as Record +})) +vi.mock('$app/state', () => ({ page: state })) +vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() })) +vi.mock('$lib/compositions/switchTenant', () => ({ switchTenant: vi.fn() })) +// Dialog bodies the menu can open; they fetch on import. +vi.mock('$lib/components/other/ApiKeyMenu.svelte', () => ({ default: () => {} })) +vi.mock('$lib/components/other/OidcTrustMenu.svelte', () => ({ default: () => {} })) + +// Imported AFTER vi.mock so the mocks take effect. +import ProfileButton from './ProfileButton.svelte' + +const auth = { + logout: vi.fn(), + profile: { name: 'Ada', email: 'ada@example.com' }, + userInfo: {}, + accessToken: '' +} + +// Takes the session payload's `role` as the server spells it, and derives the +// rest the way `buildFelderaData` does. The server sends `role` and the acting +// tenant fields together or not at all. +const session = (sessionRole: string | null, tenantId: string) => { + const role = roleOf(sessionRole) + return { + auth, + feldera: { + tenantId, + tenantName: tenantId ? 'acme' : '', + role, + permissions: permissionsOf(role), + memberships: [], + edition: 'Open source', + version: '1.0', + revision: 'abc', + unstableFeatures: [] + } + } +} + +const healthStatus = { api: 'healthy', compiler: 'healthy', runner: 'healthy' } as const + +const openMenu = async () => { + await render(ProfileButton, { healthStatus }) + document.querySelector('button:has(.fd-circle-user)')!.click() + await expect.poll(() => document.body.textContent).toContain('Sign Out') + return document.body.textContent ?? '' +} + +describe('ProfileButton', () => { + it('offers cluster health to the lowest role that resolves a tenant', async () => { + state.data = session('read', 't-acme') + expect(await openMenu()).toContain('Feldera Health') + }) + + it('hides cluster health when no session data resolved at all', async () => { + state.data = { auth } + expect(await openMenu()).not.toContain('Feldera Health') + }) + + it('hides cluster health when the session named no role', async () => { + // What the server sends without an acting tenant: no role, so no permissions. + state.data = session(null, '') + const menu = await openMenu() + expect(menu).not.toContain('Feldera Health') + // The write-gated entries close through the same empty list. + expect(menu).not.toContain('Admin Dashboard') + expect(menu).not.toContain('Manage API keys') + }) + + it('still offers cluster health to an owner, so the gate is not rank-based', async () => { + state.data = session('owner', 't-acme') + expect(await openMenu()).toContain('Feldera Health') + }) +}) diff --git a/js-packages/web-console/src/lib/components/auth/TenantPicker.svelte b/js-packages/web-console/src/lib/components/auth/TenantPicker.svelte new file mode 100644 index 00000000000..638e3b0cadc --- /dev/null +++ b/js-packages/web-console/src/lib/components/auth/TenantPicker.svelte @@ -0,0 +1,52 @@ + + +
+ {#if memberships.length > 0} +

Choose a tenant

+ {:else} +

No tenant access

+ {/if} +
+ {#if memberships.length > 0} +

+ Your account is a member of several tenants.
Pick the one to work in; you can switch + later from the profile popup menu in the top right. +

+
+ {#each memberships as membership (membership.tenantId)} + + {/each} +
+ {:else} +

+ You have no tenant access yet. Ask an administrator to add you to a tenant, then reload this + page. +

+ {/if} +
+
diff --git a/js-packages/web-console/src/lib/components/auth/TenantPicker.svelte.spec.ts b/js-packages/web-console/src/lib/components/auth/TenantPicker.svelte.spec.ts new file mode 100644 index 00000000000..7a2555d7806 --- /dev/null +++ b/js-packages/web-console/src/lib/components/auth/TenantPicker.svelte.spec.ts @@ -0,0 +1,63 @@ +// TenantPicker is the gate shown when a login resolves no acting tenant: with +// memberships it lists them for a one-click selection, without any it explains +// that an administrator must grant access first. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { page } from 'vitest/browser' +import { render } from 'vitest-browser-svelte' + +const switchTenant = vi.hoisted(() => vi.fn()) +const takeRedirectTarget = vi.hoisted(() => vi.fn()) +vi.mock('$lib/compositions/switchTenant', () => ({ + switchTenant: (...args: unknown[]) => switchTenant(...args) +})) +vi.mock('$lib/services/redirectTarget', () => ({ + takeRedirectTarget: () => takeRedirectTarget() +})) + +// Imported AFTER vi.mock so the mock takes effect. +import TenantPicker from './TenantPicker.svelte' + +const memberships = [ + { tenantId: 't-acme', name: 'acme', role: 'admin' }, + { tenantId: 't-beta', name: 'beta', role: 'read' } +] + +describe('TenantPicker', () => { + afterEach(() => { + switchTenant.mockReset() + takeRedirectTarget.mockReset() + }) + + it('lists every membership with its name and role', async () => { + await render(TenantPicker, { memberships }) + await expect.element(page.getByText('Choose a tenant')).toBeInTheDocument() + await expect.element(page.getByText('acme', { exact: true })).toBeInTheDocument() + await expect.element(page.getByText('admin', { exact: true })).toBeInTheDocument() + await expect.element(page.getByText('beta', { exact: true })).toBeInTheDocument() + await expect.element(page.getByText('read', { exact: true })).toBeInTheDocument() + }) + + it('selects a tenant by id and restarts on the page the gate interrupted', async () => { + takeRedirectTarget.mockReturnValue('http://localhost/pipelines/foo/') + await render(TenantPicker, { memberships }) + await page.getByRole('button', { name: /beta/ }).click() + expect(switchTenant.mock.calls).toEqual([['t-beta', { to: 'http://localhost/pipelines/foo/' }]]) + }) + + it('leaves the restart target unset when nothing was interrupted', async () => { + takeRedirectTarget.mockReturnValue(undefined) + await render(TenantPicker, { memberships }) + await page.getByRole('button', { name: /beta/ }).click() + expect(switchTenant.mock.calls).toEqual([['t-beta', { to: undefined }]]) + }) + + it('shows the no-access notice when there are no memberships', async () => { + await render(TenantPicker, { memberships: [] }) + await expect + .element(page.getByRole('heading', { name: 'No tenant access' })) + .toBeInTheDocument() + await expect.element(page.getByText(/Ask an administrator/)).toBeInTheDocument() + expect(switchTenant).not.toHaveBeenCalled() + }) +}) diff --git a/js-packages/web-console/src/lib/components/other/OidcTrustMenu.svelte b/js-packages/web-console/src/lib/components/other/OidcTrustMenu.svelte index 974c66830e6..71ace27c3f7 100644 --- a/js-packages/web-console/src/lib/components/other/OidcTrustMenu.svelte +++ b/js-packages/web-console/src/lib/components/other/OidcTrustMenu.svelte @@ -73,7 +73,6 @@ onscroll={() => (scrollTop = scrollEl?.scrollTop ?? 0)} class="-mr-4 scrollbar h-full overflow-auto pr-4 sm:-mr-8 sm:pr-8" > -

Grant read/write/admin to workloads (CI, services) in tenant {tenantName} by trusting JWTs from an issuer. diff --git a/js-packages/web-console/src/lib/compositions/configCache.spec.ts b/js-packages/web-console/src/lib/compositions/configCache.spec.ts index 0ff55cd99de..09260cdb899 100644 --- a/js-packages/web-console/src/lib/compositions/configCache.spec.ts +++ b/js-packages/web-console/src/lib/compositions/configCache.spec.ts @@ -1,7 +1,8 @@ // Unit tests for the warm-cache machinery used by the root `+layout.ts` // boot-up flow: cached reads (hit/miss/corrupt), writes, the lazy-load -// `fetchConfigs` orchestrator (success + failure), and `configChanged` -// reconcile semantics that gate `invalidateAll()` after a warm-cache render. +// `fetchConfigs` orchestrator (success, failure, unresolved-tenant session, +// invalid-selection retry), and `configChanged` reconcile semantics that gate +// `invalidateAll()` after a warm-cache render. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Configuration, SessionInfo } from '$lib/services/manager' @@ -11,11 +12,25 @@ vi.mock('$lib/services/pipelineManager', () => ({ getConfigSession: vi.fn() })) +// The tenant re-check latch is real here (it gates the warm-cache read); only +// its `invalidateAll()` side effect is stubbed. +vi.mock('$app/navigation', () => ({ invalidateAll: vi.fn() })) + +const selection = vi.hoisted(() => ({ current: undefined as string | undefined })) +vi.mock('$lib/services/auth', () => ({ + getSelectedTenant: vi.fn(() => selection.current), + setSelectedTenant: vi.fn((tenant?: string) => { + selection.current = tenant + }) +})) + +import { requestTenantRecheck, resetTenantRecheck } from '$lib/compositions/tenantAccess' import { getConfig, getConfigSession } from '$lib/services/pipelineManager' import { clearConfigCaches, configChanged, fetchConfigs, + getCachedConfigsForRender, getConfigFromCache, getSessionConfigFromCache, setConfigCache, @@ -67,6 +82,8 @@ beforeEach(() => { vi.stubGlobal('localStorage', new MemoryStorage()) mockedGetConfig.mockReset() mockedGetConfigSession.mockReset() + selection.current = undefined + resetTenantRecheck() }) afterEach(() => { @@ -113,6 +130,30 @@ describe('configCache primitives', () => { }) }) +describe('getCachedConfigsForRender', () => { + it('hands over both cached payloads on an ordinary warm start', () => { + setConfigCache(baseConfig) + setSessionConfigCache(sessionConfig) + expect(getCachedConfigsForRender()).toEqual({ config: baseConfig, sessionConfig }) + }) + + it('withholds them while a tenant re-check is pending', () => { + setConfigCache(baseConfig) + setSessionConfigCache(sessionConfig) + requestTenantRecheck() + // The cache still names the tenant this session just lost, so a warm render + // from it would leave the app up with every request failing. + expect(getCachedConfigsForRender()).toEqual({}) + }) + + it('hands them over again once access is regained', () => { + setConfigCache(baseConfig) + requestTenantRecheck() + resetTenantRecheck() + expect(getCachedConfigsForRender().config).toEqual(baseConfig) + }) +}) + describe('fetchConfigs', () => { it('caches both payloads on a successful fetch', async () => { mockedGetConfig.mockResolvedValueOnce(baseConfig) @@ -158,6 +199,84 @@ describe('fetchConfigs', () => { expect(getSessionConfigFromCache()).toBeUndefined() }) + + // The session answering with `tenant_id: null` is the must-pick (or + // no-access) state: /config rejects alongside, which is expected rather than + // an error, and the caches must not keep presenting the previous tenant. + it('returns the unresolved-tenant session without a config and drops the caches', async () => { + setConfigCache(baseConfig) + setSessionConfigCache(sessionConfig) + const unresolvedSession = { + tenant_id: null, + tenant_name: null, + role: null, + memberships: [{ tenant_id: 't-1', name: 'acme', role: 'admin' }] + } as unknown as SessionInfo + mockedGetConfig.mockRejectedValueOnce( + new Error('ambiguous', { cause: { error_code: 'AmbiguousTenantMembership' } }) + ) + mockedGetConfigSession.mockResolvedValueOnce(unresolvedSession) + + const result = await fetchConfigs() + + expect(result.config).toBeUndefined() + expect(result.sessionConfig).toEqual(unresolvedSession) + expect(getConfigFromCache()).toBeUndefined() + expect(getSessionConfigFromCache()).toBeUndefined() + }) + + it('drops an invalid saved selection and retries once headerless', async () => { + selection.current = 'gone-tenant' + const notAMember = () => + new Error('not a member', { cause: { error_code: 'NotATenantMember' } }) + mockedGetConfig.mockRejectedValueOnce(notAMember()).mockResolvedValueOnce(baseConfig) + mockedGetConfigSession.mockRejectedValueOnce(notAMember()).mockResolvedValueOnce(sessionConfig) + + const result = await fetchConfigs() + + expect(selection.current).toBeUndefined() + expect(result.config).toEqual(baseConfig) + expect(mockedGetConfig).toHaveBeenCalledTimes(2) + }) + + // Owners hold no memberships, so a deleted acted-as tenant surfaces as + // UnknownTenantName rather than NotATenantMember; recovery is the same. + it('drops an invalid owner act-as selection on UnknownTenantName and retries once', async () => { + selection.current = 'deleted-tenant' + const unknownTenant = () => + new Error('unknown tenant', { cause: { error_code: 'UnknownTenantName' } }) + mockedGetConfig.mockRejectedValueOnce(unknownTenant()).mockResolvedValueOnce(baseConfig) + mockedGetConfigSession + .mockRejectedValueOnce(unknownTenant()) + .mockResolvedValueOnce(sessionConfig) + + const result = await fetchConfigs() + + expect(selection.current).toBeUndefined() + expect(result.config).toEqual(baseConfig) + expect(mockedGetConfig).toHaveBeenCalledTimes(2) + }) + + it('retries at most once on NotATenantMember', async () => { + selection.current = 'gone-tenant' + const notAMember = () => + new Error('not a member', { cause: { error_code: 'NotATenantMember' } }) + mockedGetConfig.mockRejectedValue(notAMember()) + mockedGetConfigSession.mockRejectedValue(notAMember()) + + await expect(fetchConfigs()).rejects.toThrow('not a member') + expect(mockedGetConfig).toHaveBeenCalledTimes(2) + }) + + it('does not retry NotATenantMember when no selection is saved', async () => { + const notAMember = () => + new Error('not a member', { cause: { error_code: 'NotATenantMember' } }) + mockedGetConfig.mockRejectedValueOnce(notAMember()) + mockedGetConfigSession.mockRejectedValueOnce(notAMember()) + + await expect(fetchConfigs()).rejects.toThrow('not a member') + expect(mockedGetConfig).toHaveBeenCalledTimes(1) + }) }) describe('configChanged (cache reconcile)', () => { diff --git a/js-packages/web-console/src/lib/compositions/configCache.ts b/js-packages/web-console/src/lib/compositions/configCache.ts index dee2ce13a46..95c07285125 100644 --- a/js-packages/web-console/src/lib/compositions/configCache.ts +++ b/js-packages/web-console/src/lib/compositions/configCache.ts @@ -1,4 +1,10 @@ import equal from 'fast-deep-equal' +import { + errorCodeOf, + isTenantRecheckPending, + resetTenantRecheck +} from '$lib/compositions/tenantAccess' +import { getSelectedTenant, setSelectedTenant } from '$lib/services/auth' import type { Configuration, SessionInfo } from '$lib/services/manager' import { getConfig, getConfigSession } from '$lib/services/pipelineManager' @@ -45,16 +51,90 @@ export const setSessionConfigCache = (sessionConfig: SessionInfo | undefined) => } } -// Fetch fresh config and session config -export const fetchConfigs = async () => { - const [config, sessionConfig] = await Promise.all([getConfig(), getConfigSession()]) +/** + * The cached payloads a warm-cache render may use, or nothing when it must not. + * + * A pending tenant re-check means the server told this session it holds no + * membership, so the cache still describes a tenant it no longer resolves. + * Rendering from it would leave the app up while every request fails, so the + * caller has to fetch and discover the unresolved state instead. + */ +export const getCachedConfigsForRender = (): { + config?: Configuration + sessionConfig?: SessionInfo +} => { + if (isTenantRecheckPending()) { + return {} + } + return { config: getConfigFromCache(), sessionConfig: getSessionConfigFromCache() } +} + +const fetchConfigsOnce = async (): Promise<{ + config: Configuration | undefined + sessionConfig: SessionInfo | undefined +}> => { + const [config, sessionConfig] = await Promise.allSettled([getConfig(), getConfigSession()]) + + if ( + sessionConfig.status === 'fulfilled' && + sessionConfig.value && + sessionConfig.value.tenant_id == null + ) { + // The login resolved no acting tenant (several memberships and no saved + // selection, or none at all). /config/session is the only route that + // answers in this state, so /config rejecting alongside is expected, not + // an error. Drop the cached payloads: they describe a tenant this session + // no longer resolves, and a warm-cache render from them would present the + // app as if a tenant were still active. + clearConfigCaches() + return { config: undefined, sessionConfig: sessionConfig.value } + } + + if (config.status === 'rejected') { + throw config.reason + } + if (sessionConfig.status === 'rejected') { + throw sessionConfig.reason + } - if (config) { - setConfigCache(config) - setSessionConfigCache(sessionConfig) + if (config.value) { + setConfigCache(config.value) + setSessionConfigCache(sessionConfig.value) } - return { config, sessionConfig } + return { config: config.value, sessionConfig: sessionConfig.value } +} + +// Error codes that mean the saved tenant selection stopped being valid, not +// that the request itself is broken: a member removed from the selected tenant +// gets `NotATenantMember`; an owner whose acted-as tenant was deleted gets +// `UnknownTenantName` (owners hold no memberships, so the server reports the +// missing tenant itself). +const INVALID_SELECTION_ERROR_CODES = ['NotATenantMember', 'UnknownTenantName'] + +/** + * Fetch fresh config and session config, updating the localStorage cache. + * + * When the saved tenant selection stopped being valid (see + * `INVALID_SELECTION_ERROR_CODES`), recover by dropping the selection and + * retrying once headerless; the retry cannot loop because it runs with no + * selection left to reject. + */ +export const fetchConfigs = async () => { + try { + const fetched = await fetchConfigsOnce() + resetTenantRecheck() + return fetched + } catch (e) { + const code = errorCodeOf(e) + if (!code || !INVALID_SELECTION_ERROR_CODES.includes(code) || !getSelectedTenant()) { + throw e + } + setSelectedTenant(undefined) + const retried = await fetchConfigsOnce() + resetTenantRecheck() + return retried + } } /** diff --git a/js-packages/web-console/src/lib/compositions/health/useClusterHealth.svelte.ts b/js-packages/web-console/src/lib/compositions/health/useClusterHealth.svelte.ts index f6f063d782b..1537f7ff4a9 100644 --- a/js-packages/web-console/src/lib/compositions/health/useClusterHealth.svelte.ts +++ b/js-packages/web-console/src/lib/compositions/health/useClusterHealth.svelte.ts @@ -13,7 +13,7 @@ let status = $state({ /** * Poll cluster health every 10 seconds (with an immediate first call) and * publish the result to the module-level `status` store. A single instance of - * this hook should be mounted at one time (the authenticated layout owns it); + * this hook should be mounted at one time (the `(authorized)` layout owns it); * consumers read the state via {@link useClusterHealth}. */ export const useRefreshClusterHealth = () => { diff --git a/js-packages/web-console/src/lib/compositions/switchTenant.spec.ts b/js-packages/web-console/src/lib/compositions/switchTenant.spec.ts new file mode 100644 index 00000000000..0779d3ff2c5 --- /dev/null +++ b/js-packages/web-console/src/lib/compositions/switchTenant.spec.ts @@ -0,0 +1,52 @@ +// switchTenant persists the selection, drops the config caches, and restarts +// the app: on the home page by default (header switcher), or on the page named +// by `to` (the tenant page, restoring the link the gate interrupted). + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const setSelectedTenant = vi.hoisted(() => vi.fn()) +const clearConfigCaches = vi.hoisted(() => vi.fn()) + +vi.mock('$lib/services/auth', () => ({ + setSelectedTenant: (...args: unknown[]) => setSelectedTenant(...args) +})) +vi.mock('$lib/compositions/configCache', () => ({ + clearConfigCaches: () => clearConfigCaches() +})) +vi.mock('$lib/functions/svelte', () => ({ + resolve: (path: string) => path +})) + +import { switchTenant } from './switchTenant' + +const location = { assign: vi.fn(), reload: vi.fn() } + +beforeEach(() => { + vi.stubGlobal('window', { location }) + setSelectedTenant.mockReset() + clearConfigCaches.mockReset() + location.assign.mockReset() + location.reload.mockReset() +}) + +describe('switchTenant', () => { + it('saves the selection, clears the caches, and restarts on the home page', () => { + switchTenant('t-acme') + expect(setSelectedTenant.mock.calls).toEqual([['t-acme']]) + expect(clearConfigCaches).toHaveBeenCalledOnce() + expect(location.assign.mock.calls).toEqual([['/']]) + expect(location.reload).not.toHaveBeenCalled() + }) + + it('restarts on the page named by `to`, preserving a deep link', () => { + switchTenant('t-acme', { to: 'http://localhost/pipelines/foo/' }) + expect(setSelectedTenant.mock.calls).toEqual([['t-acme']]) + expect(clearConfigCaches).toHaveBeenCalledOnce() + expect(location.assign.mock.calls).toEqual([['http://localhost/pipelines/foo/']]) + }) + + it('falls back to the home page when `to` is unset', () => { + switchTenant('t-acme', { to: undefined }) + expect(location.assign.mock.calls).toEqual([['/']]) + }) +}) diff --git a/js-packages/web-console/src/lib/compositions/switchTenant.ts b/js-packages/web-console/src/lib/compositions/switchTenant.ts new file mode 100644 index 00000000000..8b455cd8ca9 --- /dev/null +++ b/js-packages/web-console/src/lib/compositions/switchTenant.ts @@ -0,0 +1,22 @@ +import { clearConfigCaches } from '$lib/compositions/configCache' +import { resolve } from '$lib/functions/svelte' +import { setSelectedTenant } from '$lib/services/auth' + +/** + * Persist the tenant selection (by tenant id) and restart the app. + * + * A full page load rather than `invalidateAll()`: layout components persist + * across invalidation, so module state and pollers initialized for the old + * tenant would carry over. A fresh page start re-fetches /config/session under + * the new `Feldera-Tenant` header from a clean slate. + * + * `to` names where to restart, for the tenant page: a user who deep-linked into + * a page should land there once a tenant is chosen. The header switcher omits it + * and restarts on the home page, because the current page is tenant-scoped and + * may not exist in the tenant switched to. + */ +export const switchTenant = (tenantId: string, options?: { to?: string }) => { + setSelectedTenant(tenantId) + clearConfigCaches() + window.location.assign(options?.to ?? resolve('/')) +} diff --git a/js-packages/web-console/src/lib/compositions/tenantAccess.spec.ts b/js-packages/web-console/src/lib/compositions/tenantAccess.spec.ts new file mode 100644 index 00000000000..7e1b6e33eb5 --- /dev/null +++ b/js-packages/web-console/src/lib/compositions/tenantAccess.spec.ts @@ -0,0 +1,86 @@ +// Losing the last membership mid-session is reported by whatever request +// happens to fail, so the global error interceptor is what notices it. All it +// can do is re-run the loaders: the root `load()` then resolves no acting +// tenant and the `(authorized)` gate redirects to the tenant page. + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const invalidateAll = vi.hoisted(() => vi.fn()) +vi.mock('@axa-fr/oidc-client', () => ({ OidcClient: { get: vi.fn() } })) +vi.mock('$app/navigation', () => ({ invalidateAll: () => invalidateAll() })) + +import { errorResponseMiddleware } from '$lib/services/auth' +import { errorCodeOf, isTenantRecheckPending, resetTenantRecheck } from './tenantAccess' + +const rejection = (code: string) => new Error(code, { cause: { error_code: code } }) + +beforeEach(() => { + resetTenantRecheck() + invalidateAll.mockReset() +}) + +describe('errorCodeOf', () => { + it('reads the code from the cause an SDK rejection carries', () => { + expect(errorCodeOf(rejection('NoTenantMemberships'))).toBe('NoTenantMemberships') + }) + + it('reads a code sitting directly on the error body', () => { + expect(errorCodeOf({ error_code: 'NotATenantMember' })).toBe('NotATenantMember') + }) + + it('is undefined for anything else', () => { + expect(errorCodeOf(new Error('boom'))).toBeUndefined() + expect(errorCodeOf(undefined)).toBeUndefined() + }) +}) + +describe('tenant re-check', () => { + it('starts unarmed', () => { + expect(isTenantRecheckPending()).toBe(false) + }) + + it('re-runs the loaders when a response reports no memberships', () => { + errorResponseMiddleware(rejection('NoTenantMemberships'), undefined) + expect(invalidateAll).toHaveBeenCalledOnce() + expect(isTenantRecheckPending()).toBe(true) + }) + + it('re-runs them once however many requests report it', () => { + // The re-run itself fetches /config, which fails the same way while no + // tenant resolves; without the latch that is an endless loop. + errorResponseMiddleware(rejection('NoTenantMemberships'), undefined) + errorResponseMiddleware(rejection('NoTenantMemberships'), undefined) + errorResponseMiddleware(rejection('NoTenantMemberships'), undefined) + expect(invalidateAll).toHaveBeenCalledOnce() + }) + + it('ignores codes that a retry or a reload can still recover from', () => { + for (const code of ['NotATenantMember', 'UnknownTenantName', 'AmbiguousTenantMembership']) { + errorResponseMiddleware(rejection(code), undefined) + expect(isTenantRecheckPending(), code).toBe(false) + } + expect(invalidateAll).not.toHaveBeenCalled() + }) + + it('ignores ordinary failures, including network errors with no response', () => { + errorResponseMiddleware(new TypeError('Failed to fetch'), undefined) + errorResponseMiddleware({ message: 'nope' }, { status: 500 } as Response) + expect(isTenantRecheckPending()).toBe(false) + expect(invalidateAll).not.toHaveBeenCalled() + }) + + it('still tags the error with status, as before', () => { + const error: any = rejection('NoTenantMemberships') + const out: any = errorResponseMiddleware(error, { status: 403 } as Response) + expect(out.status).toBe(403) + expect(isTenantRecheckPending()).toBe(true) + }) + + it('re-arms once access is regained, so a later revocation is noticed', () => { + errorResponseMiddleware(rejection('NoTenantMemberships'), undefined) + resetTenantRecheck() + expect(isTenantRecheckPending()).toBe(false) + errorResponseMiddleware(rejection('NoTenantMemberships'), undefined) + expect(invalidateAll).toHaveBeenCalledTimes(2) + }) +}) diff --git a/js-packages/web-console/src/lib/compositions/tenantAccess.ts b/js-packages/web-console/src/lib/compositions/tenantAccess.ts new file mode 100644 index 00000000000..27c93bb252c --- /dev/null +++ b/js-packages/web-console/src/lib/compositions/tenantAccess.ts @@ -0,0 +1,40 @@ +import { invalidateAll } from '$app/navigation' + +/** The `error_code` an SDK rejection carries, either directly or as its cause. */ +export const errorCodeOf = (reason: unknown): string | undefined => { + const cause = (reason as { cause?: { error_code?: string } } | undefined)?.cause + return cause?.error_code ?? (reason as { error_code?: string } | undefined)?.error_code +} + +let recheckPending = false + +/** + * Re-run the loaders because the server says this session holds no tenant + * membership at all, which happens when an administrator removes the last one + * while the user is working. The root `load()` then resolves no acting tenant, + * and the `(authorized)` group's gate redirects to the tenant page. + * + * Called from the global error interceptor, so a single revocation can be + * reported by many failing requests: the latch keeps that to one re-run. It + * also stops the re-run from re-entering itself, since `load()` fetches + * `/config`, which fails the same way while no tenant resolves. + */ +export const requestTenantRecheck = () => { + if (recheckPending) { + return + } + recheckPending = true + invalidateAll() +} + +/** + * Whether a re-check is in flight. The root `load()` consults this to bypass its + * warm config cache: that cache still describes the tenant the session just lost, + * and rendering from it would leave the app up with every request failing. + */ +export const isTenantRecheckPending = () => recheckPending + +/** Called when a config fetch succeeds, so regained access re-arms the latch. */ +export const resetTenantRecheck = () => { + recheckPending = false +} diff --git a/js-packages/web-console/src/lib/services/auth.spec.ts b/js-packages/web-console/src/lib/services/auth.spec.ts new file mode 100644 index 00000000000..f8e477936a8 --- /dev/null +++ b/js-packages/web-console/src/lib/services/auth.spec.ts @@ -0,0 +1,81 @@ +// Unit tests for the per-user tenant selection: the saved selection is keyed +// to the logged-in user's OIDC `sub`, is unreadable before a user is known, +// and never leaks between users sharing a browser. + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@axa-fr/oidc-client', () => ({ OidcClient: { get: vi.fn() } })) + +import { getSelectedTenant, setSelectedTenant, setSelectedTenantUser } from './auth' + +class MemoryStorage { + private store = new Map() + getItem = (k: string) => (this.store.has(k) ? (this.store.get(k) as string) : null) + setItem = (k: string, v: string) => { + this.store.set(k, String(v)) + } + removeItem = (k: string) => { + this.store.delete(k) + } +} + +let storage: MemoryStorage + +beforeEach(() => { + storage = new MemoryStorage() + vi.stubGlobal('window', { localStorage: storage }) +}) + +describe('per-user tenant selection', () => { + // Must run first: it relies on the module's pristine no-user-known state, + // which later tests replace by naming a user. + it('exposes no selection and persists nothing before a user is known', () => { + expect(getSelectedTenant()).toBeUndefined() + setSelectedTenant('acme') + expect(getSelectedTenant()).toBeUndefined() + }) + + it('persists under a key derived from the user sub', () => { + setSelectedTenantUser('user-a') + setSelectedTenant('acme') + expect(storage.getItem('session/selected_tenant/user-a')).toBe('acme') + expect(getSelectedTenant()).toBe('acme') + }) + + it('loads the saved selection when the user becomes known', () => { + storage.setItem('session/selected_tenant/user-a', 'acme') + setSelectedTenantUser('user-a') + expect(getSelectedTenant()).toBe('acme') + }) + + it('does not carry one user selection over to another', () => { + setSelectedTenantUser('user-a') + setSelectedTenant('acme') + setSelectedTenantUser('user-b') + expect(getSelectedTenant()).toBeUndefined() + }) + + it('clears the saved selection when set to undefined', () => { + setSelectedTenantUser('user-a') + setSelectedTenant('acme') + setSelectedTenant(undefined) + expect(getSelectedTenant()).toBeUndefined() + expect(storage.getItem('session/selected_tenant/user-a')).toBeNull() + }) + + // Logout does not clear the selection (see onBeforeLogout in +layout.ts): + // the key is per user, so a returning user resumes their tenant. + it('restores the same user selection across a logout/login cycle', () => { + setSelectedTenantUser('user-a') + setSelectedTenant('acme') + setSelectedTenantUser('user-a') + expect(getSelectedTenant()).toBe('acme') + }) + + it('drops the legacy user-agnostic key so it cannot leak into a login', () => { + storage.setItem('session/selected_tenant', 'stale') + setSelectedTenantUser('user-a') + expect(storage.getItem('session/selected_tenant')).toBeNull() + expect(getSelectedTenant()).toBeUndefined() + }) +}) diff --git a/js-packages/web-console/src/lib/services/auth.ts b/js-packages/web-console/src/lib/services/auth.ts index a3fa0604cc2..5c31d653a93 100644 --- a/js-packages/web-console/src/lib/services/auth.ts +++ b/js-packages/web-console/src/lib/services/auth.ts @@ -1,22 +1,46 @@ import * as AxaOidc from '@axa-fr/oidc-client' +import { errorCodeOf, requestTenantRecheck } from '$lib/compositions/tenantAccess' +import { stashRedirectTarget } from '$lib/services/redirectTarget' const { OidcClient } = AxaOidc -let selectedTenant: string | undefined = - ('window' in globalThis ? window.localStorage.getItem('session/selected_tenant') : undefined) ?? - undefined +// The tenant selection is stored per user ('session/selected_tenant/') so +// two people sharing a browser never inherit each other's selection. Until +// `setSelectedTenantUser` names the logged-in user, no selection is readable +// and none can be persisted. +const SELECTED_TENANT_KEY_PREFIX = 'session/selected_tenant/' + +let selectedTenantStorageKey: string | undefined +let selectedTenant: string | undefined + +/** + * Key the tenant selection to the logged-in user (OIDC `sub`) and load that + * user's saved selection. Must run before the first request that attaches the + * `Feldera-Tenant` header (see the auth init in `routes/+layout.ts`). + */ +export const setSelectedTenantUser = (sub: string) => { + // The pre-per-user key is not scoped to any user, so an old selection there + // must not leak into this login. + window.localStorage.removeItem('session/selected_tenant') + selectedTenantStorageKey = SELECTED_TENANT_KEY_PREFIX + sub + selectedTenant = window.localStorage.getItem(selectedTenantStorageKey) ?? undefined +} export const getSelectedTenant = () => { return selectedTenant } export const setSelectedTenant = (tenant?: string) => { + if (!selectedTenantStorageKey) { + // No user known yet: nothing to persist and no request header to shape. + return + } selectedTenant = tenant if (tenant === undefined) { - window.localStorage.removeItem('session/selected_tenant') + window.localStorage.removeItem(selectedTenantStorageKey) return } - window.localStorage.setItem('session/selected_tenant', tenant) + window.localStorage.setItem(selectedTenantStorageKey, tenant) } /** @@ -96,6 +120,13 @@ export const authResponseMiddleware = async (response: Response, request: Reques * when the interceptor fires from the fetch-failure path (network error). */ export const errorResponseMiddleware = (error: unknown, response: Response | undefined) => { + // Losing the last membership is not carried by any pending fetch, so it + // arrives here, on whatever request happens to fail. Re-running the loaders is + // what moves the session to the tenant gate; until then the app renders + // against layout data that still believes a tenant is resolved. + if (errorCodeOf(error) === 'NoTenantMemberships') { + requestTenantRecheck() + } if (error && typeof error === 'object') { try { ;(error as any).response = response @@ -117,12 +148,10 @@ export const errorResponseMiddleware = (error: unknown, response: Response | und /** * Start an OIDC re-authentication flow. * - * Stashes the current URL under `redirect_to` in session storage so the - * `onAfterLogin` hook in `routes/+layout.ts` can restore it via `goto` once - * the provider callback completes. Only writes the key if it isn't already - * set, so the *first* call (which captures the user's actual page) wins — - * the fallback navigation below re-invokes this function from `/`, and we - * must not overwrite the original target `redirect_to` with `/`. + * Stashes the current URL so the `onAfterLogin` hook in `routes/+layout.ts` can + * restore it via `goto` once the provider callback completes (see + * {@link stashRedirectTarget}, which the fallback navigation below relies on to + * keep the page originally asked for). * * If the OIDC singleton has not been initialized yet — e.g. the backend was * unauthenticated when the root layout first loaded and has since flipped to @@ -133,9 +162,7 @@ export const errorResponseMiddleware = (error: unknown, response: Response | und * available — and complete the redirect to the provider. */ export const triggerOidcLogin = async (): Promise => { - if (!window.sessionStorage.getItem('redirect_to')) { - window.sessionStorage.setItem('redirect_to', window.location.href) - } + stashRedirectTarget(window.location.href) let oidcClient try { oidcClient = OidcClient.get() diff --git a/js-packages/web-console/src/lib/services/manager/index.ts b/js-packages/web-console/src/lib/services/manager/index.ts index 70bd857a295..e99d454af0c 100644 --- a/js-packages/web-console/src/lib/services/manager/index.ts +++ b/js-packages/web-console/src/lib/services/manager/index.ts @@ -42,6 +42,7 @@ export { getPipelineTimeSeries, getPipelineTimeSeriesStream, getRemoteCheckpoints, + getTenant, httpInput, httpOutput, listApiKeys, @@ -361,6 +362,11 @@ export type { GetRemoteCheckpointsErrors, GetRemoteCheckpointsResponse, GetRemoteCheckpointsResponses, + GetTenantData, + GetTenantError, + GetTenantErrors, + GetTenantResponse, + GetTenantResponses, GlobalControllerMetrics, GlueCatalogConfig, HeaderFilter, @@ -433,6 +439,7 @@ export type { ListTenantUsersResponse, ListTenantUsersResponses, MemberRole, + MembershipOrigin, MemoryPressure, MergerType, MetricsFormat, @@ -448,7 +455,6 @@ export type { NewOidcTrustRequest, NewOidcTrustResponse, NewTenantRequest, - NewTenantResponse, NexmarkInputConfig, NexmarkInputOptions, NexmarkTable, @@ -665,6 +671,7 @@ export type { UrlInputConfig, UserAndPassword, UserId, + UserMembership, ValidateProgramRequest, ValidateProgramResponse, Version diff --git a/js-packages/web-console/src/lib/services/manager/sdk.gen.ts b/js-packages/web-console/src/lib/services/manager/sdk.gen.ts index 55c062f3fb7..272db2ffed5 100644 --- a/js-packages/web-console/src/lib/services/manager/sdk.gen.ts +++ b/js-packages/web-console/src/lib/services/manager/sdk.gen.ts @@ -125,6 +125,9 @@ import type { GetRemoteCheckpointsData, GetRemoteCheckpointsErrors, GetRemoteCheckpointsResponses, + GetTenantData, + GetTenantErrors, + GetTenantResponses, HttpInputData, HttpInputErrors, HttpInputResponses, @@ -494,6 +497,11 @@ export const getConfigOwners = ( * Required role: `read` or higher. * * Retrieve login session information for your current user session. + * + * This is the one route that answers a login without a resolved acting + * tenant: when the user belongs to several tenants (or none) and no + * `Feldera-Tenant` header selects one, the acting-tenant fields are `null` + * and `memberships` lists the tenants to pick from. */ export const getConfigSession = ( options?: Options @@ -1122,7 +1130,7 @@ export const getPipelineDataflowGraph = ( /** * Compute Program Diff * - * Required role: `read` or higher. + * Required role: `write` or higher. * * Compute the diff between the pipeline's current program and a proposed new * version, without modifying or restarting the pipeline. @@ -1933,9 +1941,11 @@ export const listTenantUsers = ( * Required role: `admin` or higher. * * Add a member to the acting tenant by identity, before the user's first - * login. The grant is dormant until that identity authenticates into the - * tenant through the IdP. The role is capped at the caller's own role and may - * not be `owner`. + * login. The membership authorizes on its own: as soon as that identity + * authenticates through the platform's identity provider, the user may act + * in this tenant, and a headerless login with exactly this one membership + * lands in it. The role is capped at the caller's own role and may not be + * `owner`. */ export const addTenantUser = ( options: Options @@ -2029,8 +2039,7 @@ export const listTenants = ( * * Explicitly create a tenant, rather than relying on first login. * A login resolves its tenant by name, so a user whose identity provider - * asserts this name lands in the tenant created here. Fails with a conflict if - * the name is already taken. + * asserts this name lands in the tenant created here. */ export const createTenant = ( options: Options @@ -2075,6 +2084,24 @@ export const deleteTenant = ( ...options }) +/** + * Get Tenant + * + * Required role: `owner`. + * + * Retrieve a single tenant by name or identifier. A selector that parses as a + * UUID is looked up by tenant identifier, otherwise by name. + */ +export const getTenant = ( + options: Options +) => + (options.client ?? client).get({ + responseStyle: 'data', + security: [{ scheme: 'bearer', type: 'http' }], + url: '/v0/tenants/{tenant_id}', + ...options + }) + /** * Rename Tenant * @@ -2106,7 +2133,7 @@ export const patchTenant = ( /** * Validate Program * - * Required role: `read` or higher. + * Required role: `write` or higher. * * Validate a SQL program by compiling it, without creating a pipeline or * building the pipeline binary. Reports SQL errors and warnings and the derived diff --git a/js-packages/web-console/src/lib/services/manager/types.gen.ts b/js-packages/web-console/src/lib/services/manager/types.gen.ts index 66e4515d577..7ddf00942fa 100644 --- a/js-packages/web-console/src/lib/services/manager/types.gen.ts +++ b/js-packages/web-console/src/lib/services/manager/types.gen.ts @@ -407,6 +407,12 @@ export type ClockConfig = { * the first emitted tick. `None` means no shift is applied. */ now_offset_ms?: number | null + /** + * Constant offset added to every emitted `NOW()` value, in milliseconds + * east of UTC. Populated from the `clock_timezone_offset` pipeline + * property; 0 means UTC. + */ + timezone_offset_ms?: number } /** @@ -877,6 +883,25 @@ export type ConnectorConfig = OutputBufferConfig & { * `truncate` mode and Postgres). */ send_snapshot?: boolean + /** + * Ingest deletions as insertions, recording the original polarity in the + * `is_delete` metadata attribute. Valid for input connectors only. + * + * When `true`, a delete received by the connector is pushed to the table + * as an insertion of the same record, and the connector attaches the + * `is_delete` metadata attribute set to `true` to it. Insertions carry no + * `is_delete` attribute, so a column declared as + * `DEFAULT CAST(CONNECTOR_METADATA()['is_delete'] AS BOOLEAN)` is `NULL` + * for them. The table then contains the entire history of the input + * stream instead of tracking its current contents. + * + * Only tables without a primary key support this mode, since deletions in + * a table with a primary key delete a key rather than a record. + * + * Versions of Feldera that predate this option ignore it and apply + * deletions as regular deletions. + */ + soft_delete?: boolean /** * Start the connector after all connectors with specified labels. * @@ -2512,6 +2537,19 @@ export type IcebergReaderConfig = GlueCatalogConfig & * is used. */ datetime?: string | null + /** + * Optional final snapshot id. + * + * Valid only in `follow` and `snapshot_and_follow` modes. + * + * When set, the connector stops after fully ingesting the snapshot with + * this id, signaling end-of-input. Iceberg snapshot ids are not ordered, so + * the bound is an exact match: the id must name a snapshot committed after + * the starting snapshot and already present in the table's current history. + * The connector rejects any other value at startup, including a + * not-yet-committed id, rather than follow forever. + */ + end_snapshot_id?: number | null /** * Maximum number of retries for reading the table snapshot. * @@ -2605,6 +2643,8 @@ export type IcebergReaderConfig = GlueCatalogConfig & | null | number | null + | number + | null | string | null | IcebergIngestMode @@ -2626,18 +2666,26 @@ export type IcebergReaderConfig = GlueCatalogConfig & * * Determines how the connector breaks up its input into Feldera transactions. * - * * `none` - the connector does not break up its input into transactions. - * * `snapshot` - ingest the initial snapshot of the table in one or several transactions. + * * `none` - the connector does not group its input into transactions. + * * `snapshot` - ingest the initial snapshot in one or more transactions (see below). Changes + * ingested afterward, in the follow phase, are not grouped into transactions. + * * `catchup` - ingest the initial snapshot like `snapshot`. In the follow phase, the connector + * batches all currently available table commits into a single transaction. Once that transaction + * completes, it checks for commits added since the transaction began, ingests them in the next + * transaction, and repeats continuously. Most efficient for backfill and steady-state following. + * * `always` - ingest the initial snapshot like `snapshot`. In the follow phase, each table commit + * is ingested in its own transaction. * * # How the table snapshot is ingested using transactions * - * When `transaction_mode` is set to `snapshot`, the connector ingests the snapshot in one - * or several transactions, depending on `timestamp_column`. If `timestamp_column` is not set, - * the whole snapshot is ingested in a single Feldera transaction. If `timestamp_column` is set, - * the connector ingests the snapshot in a series of timestamp ranges of width equal to the - * `LATENESS` attribute of the column, each range in a separate transaction. + * For the initial snapshot (`snapshot`, `catchup`, and `always` all behave the same), the + * connector ingests the snapshot in one or several transactions, depending on `timestamp_column`. + * If `timestamp_column` is not set, the whole snapshot is ingested in a single Feldera + * transaction. If `timestamp_column` is set, the connector ingests the snapshot in a series of + * timestamp ranges of width equal to the `LATENESS` attribute of the column, each range in a + * separate transaction. */ -export type IcebergTransactionMode = 'none' | 'snapshot' +export type IcebergTransactionMode = 'none' | 'snapshot' | 'catchup' | 'always' /** * Describes an input connector configuration @@ -3153,6 +3201,11 @@ export type LicenseValidity = */ export type MemberRole = 'read' | 'write' | 'admin' +/** + * How a membership row came into existence, kept for audit. + */ +export type MembershipOrigin = 'claim' | 'derived' | 'api' + /** * Memory pressure level. * @@ -3318,14 +3371,6 @@ export type NewTenantRequest = { name: string } -/** - * Response to a successful tenant creation. - */ -export type NewTenantResponse = { - id: TenantId - name: string -} - /** * Configuration for generating Nexmark input data. * @@ -3711,6 +3756,19 @@ export type PipelineConfig = { * It is set to 1 second (1,000,000 microseconds) by default. */ clock_resolution_usecs?: number | null + /** + * Fixed timezone offset for the SQL `NOW()` clock. + * + * An ISO-8601 UTC offset, for example `"+05:30"` or `"-08:00"`, that the + * clock connector adds to every `NOW()` value it emits, so `NOW()` + * returns local time in that fixed timezone instead of UTC. + * + * The offset is baked into the pipeline's checkpointed state and cannot + * be changed when the pipeline resumes from a checkpoint: the value from + * the checkpoint stays in effect, and a differing new value is ignored + * with a warning in the pipeline log. + */ + clock_timezone_offset?: string | null /** * Enable CPU profiler. * @@ -5039,6 +5097,19 @@ export type RuntimeConfig = { * It is set to 1 second (1,000,000 microseconds) by default. */ clock_resolution_usecs?: number | null + /** + * Fixed timezone offset for the SQL `NOW()` clock. + * + * An ISO-8601 UTC offset, for example `"+05:30"` or `"-08:00"`, that the + * clock connector adds to every `NOW()` value it emits, so `NOW()` + * returns local time in that fixed timezone instead of UTC. + * + * The offset is baked into the pipeline's checkpointed state and cannot + * be changed when the pipeline resumes from a checkpoint: the value from + * the checkpoint stays in effect, and a differing new value is ignored + * with a warning in the pipeline log. + */ + clock_timezone_offset?: string | null /** * Enable CPU profiler. * @@ -5410,12 +5481,19 @@ export type ServiceStatus = { } export type SessionInfo = { - role: Role - tenant_id: TenantId /** - * Current user's tenant name + * The tenants this user may act in, from the membership table. Empty for + * principals that are not human logins (API keys, federated workloads, + * no-auth mode) and for platform owners without explicit memberships, + * who act in any tenant regardless. + */ + memberships: Array + role?: Role | null + tenant_id?: TenantId | null + /** + * Acting tenant name; `null` exactly when `tenant_id` is. */ - tenant_name: string + tenant_name?: string | null } /** @@ -5864,7 +5942,7 @@ export type TemporarySuspendError = export type TenantId = string /** - * A tenant, as returned by the platform (owner-only) tenant list. + * A tenant, as returned by the owner-only tenant endpoints. */ export type TenantInfo = { id: TenantId @@ -5882,9 +5960,23 @@ export type TenantInfo = { */ export type TenantMember = { /** - * Email, if the identity provider supplied one. + * The member's name, as the identity provider spells it; `null` until the + * provider has been asked. + */ + display_name?: string | null + /** + * Email, if the identity provider supplied one or an administrator + * recorded one when pre-provisioning the membership. */ email?: string | null + /** + * Whether the identity provider vouches for `email`. False until the + * provider has been asked, and for providers that say nothing either way. + * An email an administrator typed is never verified, so this is what + * separates an address the provider stands behind from a claim about one. + */ + email_verified?: boolean + origin?: MembershipOrigin | null /** * OIDC issuer the user authenticates through. */ @@ -6088,6 +6180,19 @@ export type UserAndPassword = { */ export type UserId = string +/** + * One tenant a user may act in, as surfaced to that user (e.g. in the + * session payload that drives the web console's tenant switcher). + */ +export type UserMembership = { + /** + * The tenant's name. + */ + name: string + role: Role + tenant_id: TenantId +} + /** * Request body for the program validation endpoint. */ @@ -8650,20 +8755,20 @@ export type CreateTenantErrors = { * Caller is not a platform owner */ 403: ErrorResponse - /** - * A tenant with that name already exists - */ - 409: ErrorResponse 500: ErrorResponse } export type CreateTenantError = CreateTenantErrors[keyof CreateTenantErrors] export type CreateTenantResponses = { + /** + * Tenant with that name already exists + */ + 200: TenantInfo /** * Tenant created */ - 201: NewTenantResponse + 201: TenantInfo } export type CreateTenantResponse = CreateTenantResponses[keyof CreateTenantResponses] @@ -8705,6 +8810,41 @@ export type DeleteTenantResponses = { 200: unknown } +export type GetTenantData = { + body?: never + path: { + /** + * Tenant name or identifier (UUID) + */ + tenant_id: string + } + query?: never + url: '/v0/tenants/{tenant_id}' +} + +export type GetTenantErrors = { + /** + * Caller is not a platform owner + */ + 403: ErrorResponse + /** + * No tenant with that name or identifier + */ + 404: ErrorResponse + 500: ErrorResponse +} + +export type GetTenantError = GetTenantErrors[keyof GetTenantErrors] + +export type GetTenantResponses = { + /** + * Tenant retrieved + */ + 200: TenantInfo +} + +export type GetTenantResponse = GetTenantResponses[keyof GetTenantResponses] + export type PatchTenantData = { body: RenameTenantRequest path: { diff --git a/js-packages/web-console/src/lib/services/rbac.spec.ts b/js-packages/web-console/src/lib/services/rbac.spec.ts index 89f7344b749..09e1b4124e9 100644 --- a/js-packages/web-console/src/lib/services/rbac.spec.ts +++ b/js-packages/web-console/src/lib/services/rbac.spec.ts @@ -11,9 +11,10 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { - DEFAULT_PERMISSIONS, hasPermission, hasPermissions, + NO_PERMISSIONS, + NO_ROLE, type Permission, permissionsOf, ROLES, @@ -24,12 +25,19 @@ import { // Cumulative expectation, stated independently of the map's internal wiring so a // bug in the precompute cannot pass by matching itself. const EXPECTED: Record = { - read: ['read:pipeline', 'read:pipeline_code', 'read:pipeline_config', 'read:support_bundle'], + read: [ + 'read:pipeline', + 'read:pipeline_code', + 'read:pipeline_config', + 'read:support_bundle', + 'read:cluster_health' + ], write: [ 'read:pipeline', 'read:pipeline_code', 'read:pipeline_config', 'read:support_bundle', + 'read:cluster_health', 'write:pipeline', 'write:pipeline_code', 'write:pipeline_config', @@ -104,6 +112,12 @@ describe('permissionsOf', () => { first.push('write:pipeline') expect(permissionsOf('read')).not.toContain('write:pipeline') }) + + it('grants nothing for NO_ROLE', () => { + // A session with no role resolved no acting tenant, and a role means nothing + // outside one. This is what closes every `` gate on the tenant page. + expect(permissionsOf(NO_ROLE)).toEqual([]) + }) }) describe('hasPermissions (session-facing)', () => { @@ -113,22 +127,42 @@ describe('hasPermissions (session-facing)', () => { expect(hasPermissions({ permissions: permissionsOf('read') }, 'write:pipeline')).toBe(false) }) - it('falls back to the read floor when the session is absent', () => { - // A rendered app shell with no session config (config-load error): reads are - // permitted, writes denied, and nothing throws. - expect(hasPermissions(undefined, 'read:pipeline')).toBe(true) - expect(hasPermissions(undefined, 'write:pipeline')).toBe(false) - expect(hasPermissions(undefined, 'write:tenant_member')).toBe(false) + it('grants nothing when the session is absent', () => { + // No `feldera` means no session resolved a tenant (booting, unauthenticated, + // a config-load error, or no tenant picked yet). Every route but + // /config/session refuses such a session, so reads are denied too, and + // nothing throws. + for (const permission of ALL_PERMISSIONS) { + expect(hasPermissions(undefined, permission), permission).toBe(false) + } }) }) -describe('DEFAULT_PERMISSIONS', () => { - it('equals the read role grant', () => { - expect(new Set(DEFAULT_PERMISSIONS)).toEqual(new Set(permissionsOf('read'))) +// How `+layout.ts` materializes `page.data.feldera.permissions`: the session's +// role string in, the gated permission list out. Asserted as a pair because the +// no-tenant case only stays closed while both halves hold. +describe('roleOf composed with permissionsOf (what the layout does)', () => { + it('grants the named role its permissions', () => { + for (const role of ROLES) { + expect(new Set(permissionsOf(roleOf(role)))).toEqual(new Set(EXPECTED[role])) + } }) - it('is frozen so a consumer reading the shared default cannot mutate it', () => { - expect(Object.isFrozen(DEFAULT_PERMISSIONS)).toBe(true) + it('grants nothing for the session the server sends without a tenant', () => { + // `role`, `tenant_id` and `tenant_name` are null together in that state. + for (const noRole of [null, undefined, '', 'superuser']) { + expect(permissionsOf(roleOf(noRole)), String(noRole)).toEqual([]) + } + }) +}) + +describe('NO_PERMISSIONS', () => { + it('is empty', () => { + expect(NO_PERMISSIONS).toEqual([]) + }) + + it('is frozen so a consumer reading the shared value cannot mutate it', () => { + expect(Object.isFrozen(NO_PERMISSIONS)).toBe(true) }) }) @@ -139,10 +173,31 @@ describe('roleOf', () => { } }) - it('defaults unknown or missing input to the least-privileged role', () => { - expect(roleOf(undefined)).toBe('read') - expect(roleOf('')).toBe('read') - expect(roleOf('superuser')).toBe('read') + it('yields NO_ROLE for absent input, since the server omits it with the tenant', () => { + // `role: null` arrives exactly when no acting tenant resolved. Reporting + // `read` there would grant a session that is refused everywhere but + // /v0/config/session the read role's permissions. + expect(roleOf(undefined)).toBe(NO_ROLE) + expect(roleOf(null)).toBe(NO_ROLE) + expect(roleOf('')).toBe(NO_ROLE) + }) + + it('yields NO_ROLE for a role this client does not model', () => { + // A backend role the map has not caught up with grants nothing rather than + // silently unlocking a feature. The drift guard below is what catches it. + expect(roleOf('superuser')).toBe(NO_ROLE) + }) + + it('never yields undefined, so `feldera.role` is always a case to handle', () => { + for (const input of [undefined, null, '', 'superuser', ...ROLES]) { + expect(roleOf(input), String(input)).not.toBeUndefined() + } + }) + + it('maps the sentinel string to NO_ROLE rather than treating it as a role', () => { + // Belt and braces: `no_role` arriving as a role name still denies. + expect(roleOf(NO_ROLE)).toBe(NO_ROLE) + expect(permissionsOf(roleOf(NO_ROLE))).toEqual([]) }) }) diff --git a/js-packages/web-console/src/lib/services/rbac.ts b/js-packages/web-console/src/lib/services/rbac.ts index f706ffe185d..c1a69c68d94 100644 --- a/js-packages/web-console/src/lib/services/rbac.ts +++ b/js-packages/web-console/src/lib/services/rbac.ts @@ -11,11 +11,25 @@ export type Role = 'read' | 'write' | 'admin' | 'owner' +/** + * What a session reports when it holds no role, which the server does exactly + * when it resolved no acting tenant: a role is granted per membership and means + * nothing outside one. + * + * A named case rather than `undefined`, so `page.data.feldera.role` is total and + * a consumer that forgets this case gets a type error instead of `undefined` + * flowing through. `Role` itself stays the four roles the backend grants, since + * that is what the permission map and the drift guard are about. + */ +export const NO_ROLE = 'no_role' +export type SessionRole = Role | typeof NO_ROLE + export type Permission = | 'read:pipeline' | 'read:pipeline_code' | 'read:pipeline_config' | 'read:support_bundle' + | 'read:cluster_health' | 'write:pipeline' | 'write:pipeline_code' | 'write:pipeline_config' @@ -35,7 +49,13 @@ export const ROLES: Role[] = ['read', 'write', 'admin', 'owner'] // What each role adds on top of the role below it (cumulative, see below). const GRANTS: Record = { - read: ['read:pipeline', 'read:pipeline_code', 'read:pipeline_config', 'read:support_bundle'], + read: [ + 'read:pipeline', + 'read:pipeline_code', + 'read:pipeline_config', + 'read:support_bundle', + 'read:cluster_health' + ], write: [ 'write:pipeline', 'write:pipeline_code', @@ -65,28 +85,41 @@ const PERMISSIONS: Record> = (() => { export const hasPermission = (role: Role, permission: Permission): boolean => PERMISSIONS[role].has(permission) -// The permissions a role grants, as a plain array. `+layout.ts` materializes -// this into `page.data.feldera.permissions` at session-config init, so UI gates -// read a data field (as if the server sent it) instead of applying the map at -// every call site. -export const permissionsOf = (role: Role): Permission[] => [...PERMISSIONS[role]] +/** + * The permissions a role grants, and none for {@link NO_ROLE}. `+layout.ts` + * materializes this into `page.data.feldera.permissions` at session-config init, + * so UI gates read a data field (as if the server sent it) instead of applying + * the map at every call site. + */ +export const permissionsOf = (role: SessionRole): Permission[] => + role === NO_ROLE ? [] : [...PERMISSIONS[role]] -// Default to the least-privileged role when the session payload lacks one, so a -// missing role never silently unlocks a feature. -export const roleOf = (role: string | undefined): Role => - (ROLES as string[]).includes(role ?? '') ? (role as Role) : 'read' +/** + * The role a session holds, or {@link NO_ROLE} when it holds none. + * + * The server sends `role: null` exactly when no acting tenant resolved, together + * with `tenant_id` and `tenant_name`. Reporting `read` for that would advertise + * features whose every request fails, since such a session is refused everywhere + * but `/v0/config/session`. + * + * An unrecognized role reads the same way: the backend gaining a role this + * client does not model grants nothing until the map catches up, rather than + * silently unlocking a feature. `rbac.spec.ts` guards against that drift. + */ +export const roleOf = (role: string | null | undefined): SessionRole => + (ROLES as string[]).includes(role ?? '') ? (role as Role) : NO_ROLE -// Fallback for a session whose config is not present yet (boot, unauthenticated, -// or a config-load error that still renders the app shell). Frozen so a consumer -// cannot mutate the shared default. -export const DEFAULT_PERMISSIONS: readonly Permission[] = Object.freeze(permissionsOf('read')) +// A session holding nothing. Frozen so a consumer reading the shared value +// cannot mutate it. +export const NO_PERMISSIONS: readonly Permission[] = Object.freeze([]) // Whether the session grants a permission. Reads the permission list -// materialized into `page.data.feldera` (see +layout.ts) and falls back to the -// read floor when `feldera` is absent, so gates never crash and never leak write -// access before the session loads. This is the session-facing check; the +// materialized into `page.data.feldera` (see +layout.ts). An absent `feldera` +// means no session resolved a tenant — booting, unauthenticated, a config-load +// error, or no tenant picked yet — and grants nothing, so gates deny by default +// the way the backend's route table does. This is the session-facing check; the // role-facing `hasPermission` above is used at init and in tests. export const hasPermissions = ( feldera: { permissions: readonly Permission[] } | undefined, permission: Permission -): boolean => (feldera?.permissions ?? DEFAULT_PERMISSIONS).includes(permission) +): boolean => (feldera?.permissions ?? NO_PERMISSIONS).includes(permission) diff --git a/js-packages/web-console/src/lib/services/redirectTarget.spec.ts b/js-packages/web-console/src/lib/services/redirectTarget.spec.ts new file mode 100644 index 00000000000..74e08dc5d72 --- /dev/null +++ b/js-packages/web-console/src/lib/services/redirectTarget.spec.ts @@ -0,0 +1,41 @@ +// The stash both the login redirect and the acting-tenant gate use to remember +// the page they interrupted. + +import { beforeEach, describe, expect, it } from 'vitest' +import { stashRedirectTarget, takeRedirectTarget } from './redirectTarget' + +const store = new Map() + +beforeEach(() => { + store.clear() + globalThis.window = { + sessionStorage: { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k) + } + } as any +}) + +describe('redirect target', () => { + it('is undefined when nothing was interrupted', () => { + expect(takeRedirectTarget()).toBeUndefined() + }) + + it('round-trips the stashed page', () => { + stashRedirectTarget('http://localhost/pipelines/foo/') + expect(takeRedirectTarget()).toBe('http://localhost/pipelines/foo/') + }) + + it('keeps the first write, so a fallback navigation cannot overwrite it', () => { + stashRedirectTarget('http://localhost/pipelines/foo/') + stashRedirectTarget('http://localhost/') + expect(takeRedirectTarget()).toBe('http://localhost/pipelines/foo/') + }) + + it('clears on read, so a later redirect cannot reuse a stale target', () => { + stashRedirectTarget('http://localhost/pipelines/foo/') + takeRedirectTarget() + expect(takeRedirectTarget()).toBeUndefined() + }) +}) diff --git a/js-packages/web-console/src/lib/services/redirectTarget.ts b/js-packages/web-console/src/lib/services/redirectTarget.ts new file mode 100644 index 00000000000..20594bb863c --- /dev/null +++ b/js-packages/web-console/src/lib/services/redirectTarget.ts @@ -0,0 +1,30 @@ +/** + * The page a redirect interrupted, so whatever interrupted it can send the user + * back. Two flows share this: the OIDC login redirect and the acting-tenant + * gate. Both take the user away from a page they asked for and owe them a way + * back to it. + * + * Session storage rather than a query parameter: the target survives the + * provider's cross-origin round trip, and it stays out of URLs the user might + * share or bookmark. + * + * Only the first write is kept, because a fallback navigation may re-enter the + * same flow from `/` and must not overwrite the page originally asked for. + * Reading takes the value: whoever restores it also consumes it, so a later + * redirect cannot land on a stale target. + */ +const REDIRECT_TARGET_KEY = 'redirect_to' + +export const stashRedirectTarget = (href: string) => { + if (!window.sessionStorage.getItem(REDIRECT_TARGET_KEY)) { + window.sessionStorage.setItem(REDIRECT_TARGET_KEY, href) + } +} + +export const takeRedirectTarget = (): string | undefined => { + const href = window.sessionStorage.getItem(REDIRECT_TARGET_KEY) + if (href) { + window.sessionStorage.removeItem(REDIRECT_TARGET_KEY) + } + return href ?? undefined +} diff --git a/js-packages/web-console/src/lib/types/auth.ts b/js-packages/web-console/src/lib/types/auth.ts index 0489c3210d2..6f570d8b611 100644 --- a/js-packages/web-console/src/lib/types/auth.ts +++ b/js-packages/web-console/src/lib/types/auth.ts @@ -7,6 +7,16 @@ export type UserProfile = { picture?: string | null } +/** + * A tenant the logged-in user may act in, from the session payload's + * membership list (see `SessionInfo.memberships`). + */ +export type TenantMembership = { + tenantId: string + name: string + role: string +} + export type SignInDetails = { logout: (params: { callbackUrl: string | undefined }) => Promise userInfo: OidcUserInfo diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/(pipelines)/create/+page.ts b/js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/(pipelines)/create/+page.ts similarity index 100% rename from js-packages/web-console/src/routes/(system)/(authenticated)/(pipelines)/create/+page.ts rename to js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/(pipelines)/create/+page.ts diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/(pipelines)/pipelines/+page.ts b/js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/(pipelines)/pipelines/+page.ts similarity index 100% rename from js-packages/web-console/src/routes/(system)/(authenticated)/(pipelines)/pipelines/+page.ts rename to js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/(pipelines)/pipelines/+page.ts diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/(pipelines)/pipelines/[pipelineName]/+layout.svelte b/js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/(pipelines)/pipelines/[pipelineName]/+layout.svelte similarity index 100% rename from js-packages/web-console/src/routes/(system)/(authenticated)/(pipelines)/pipelines/[pipelineName]/+layout.svelte rename to js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/(pipelines)/pipelines/[pipelineName]/+layout.svelte diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/(pipelines)/pipelines/[pipelineName]/+page.svelte b/js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/(pipelines)/pipelines/[pipelineName]/+page.svelte similarity index 100% rename from js-packages/web-console/src/routes/(system)/(authenticated)/(pipelines)/pipelines/[pipelineName]/+page.svelte rename to js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/(pipelines)/pipelines/[pipelineName]/+page.svelte diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/(pipelines)/pipelines/[pipelineName]/+page.ts b/js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/(pipelines)/pipelines/[pipelineName]/+page.ts similarity index 100% rename from js-packages/web-console/src/routes/(system)/(authenticated)/(pipelines)/pipelines/[pipelineName]/+page.ts rename to js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/(pipelines)/pipelines/[pipelineName]/+page.ts diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/+layout.svelte b/js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/+layout.svelte similarity index 88% rename from js-packages/web-console/src/routes/(system)/(authenticated)/+layout.svelte rename to js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/+layout.svelte index 29bb31fbb28..f1814f4b116 100644 --- a/js-packages/web-console/src/routes/(system)/(authenticated)/+layout.svelte +++ b/js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/+layout.svelte @@ -32,6 +32,9 @@ const { children, data }: { children: Snippet; data: LayoutData } = $props() + // This layout only ever mounts with an acting tenant resolved: the group's + // `+layout.ts` redirects to /select-tenant otherwise. So the pollers need no + // guard, and unmounting on the way out stops them. useRefreshPipelineList() useRefreshClusterHealth() usePipelineAction() @@ -53,13 +56,12 @@ closedIntervalAction(async () => { try { const { config } = await fetchConfigs() - const currentVersion = data.feldera?.version - const currentRevision = data.feldera?.revision - if ( - currentVersion && - currentRevision && - (config.version !== currentVersion || config.revision !== currentRevision) - ) { + // `feldera` is set for the life of this layout (the group's gate proves + // it), but `config` is not: a session that loses its acting tenant + // mid-poll gets no `Configuration` back, and that is no version change. + const currentVersion = data.feldera!.version + const currentRevision = data.feldera!.revision + if (config && (config.version !== currentVersion || config.revision !== currentRevision)) { // Automatically refresh the page data to get the new backend version await invalidateAll() @@ -196,19 +198,19 @@ {/if} {/each} +

+ --> {@render children()} { + const data = await parent() + if (data.unresolvedTenant) { + stashRedirectTarget(url.href) + throw redirect(307, resolve('/select-tenant/')) + } + loadDemos() + return {} +} diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/+page.svelte b/js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/+page.svelte similarity index 100% rename from js-packages/web-console/src/routes/(system)/(authenticated)/+page.svelte rename to js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/+page.svelte diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/+page.ts b/js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/+page.ts similarity index 100% rename from js-packages/web-console/src/routes/(system)/(authenticated)/+page.ts rename to js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/+page.ts diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/admin/+page.svelte b/js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/admin/+page.svelte similarity index 100% rename from js-packages/web-console/src/routes/(system)/(authenticated)/admin/+page.svelte rename to js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/admin/+page.svelte diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/admin/+page.ts b/js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/admin/+page.ts similarity index 100% rename from js-packages/web-console/src/routes/(system)/(authenticated)/admin/+page.ts rename to js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/admin/+page.ts diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/demos/+page.svelte b/js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/demos/+page.svelte similarity index 100% rename from js-packages/web-console/src/routes/(system)/(authenticated)/demos/+page.svelte rename to js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/demos/+page.svelte diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/demos/+page.ts b/js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/demos/+page.ts similarity index 100% rename from js-packages/web-console/src/routes/(system)/(authenticated)/demos/+page.ts rename to js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/demos/+page.ts diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/health/+page.svelte b/js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/health/+page.svelte similarity index 100% rename from js-packages/web-console/src/routes/(system)/(authenticated)/health/+page.svelte rename to js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/health/+page.svelte diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/profile-viewer/+page.svelte b/js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/profile-viewer/+page.svelte similarity index 100% rename from js-packages/web-console/src/routes/(system)/(authenticated)/profile-viewer/+page.svelte rename to js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/profile-viewer/+page.svelte diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/profile-viewer/+page.ts b/js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/profile-viewer/+page.ts similarity index 100% rename from js-packages/web-console/src/routes/(system)/(authenticated)/profile-viewer/+page.ts rename to js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/profile-viewer/+page.ts diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/tenantGate.spec.ts b/js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/tenantGate.spec.ts new file mode 100644 index 00000000000..593e20124e8 --- /dev/null +++ b/js-packages/web-console/src/routes/(system)/(authenticated)/(authorized)/tenantGate.spec.ts @@ -0,0 +1,53 @@ +/** + * The gate that keeps this group from loading or rendering without an acting + * tenant. It has to be a redirect rather than a branch in the layout component: + * a component-level branch still lets sibling `load` functions run, and several + * of them fetch tenant-scoped resources (a pipeline preload, the demo list) or + * redirect on a permission check that cannot pass without a tenant. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const loadDemos = vi.hoisted(() => vi.fn()) +const stashRedirectTarget = vi.hoisted(() => vi.fn()) + +vi.mock('$lib/compositions/useDemos.svelte', () => ({ loadDemos: () => loadDemos() })) +vi.mock('$lib/services/redirectTarget', () => ({ + stashRedirectTarget: (href: string) => stashRedirectTarget(href) +})) +vi.mock('$lib/functions/svelte', () => ({ resolve: (path: string) => path })) + +import { load } from './+layout' + +const run = (data: Record, href = 'http://localhost/pipelines/foo/') => + (load as any)({ parent: async () => data, url: new URL(href) }) + +beforeEach(() => { + loadDemos.mockReset() + stashRedirectTarget.mockReset() +}) + +describe('(authorized) gate', () => { + it('lets the group load when a tenant is resolved', async () => { + await expect(run({ feldera: { tenantId: 't-acme' } })).resolves.toEqual({}) + expect(loadDemos).toHaveBeenCalledOnce() + expect(stashRedirectTarget).not.toHaveBeenCalled() + }) + + it('redirects to the tenant page when none is resolved', async () => { + // A thrown redirect is what stops the sibling loaders; returning early would + // let them run. + const thrown: any = await run({ unresolvedTenant: { memberships: [] } }).catch((e: any) => e) + expect(thrown.status).toBe(307) + expect(thrown.location).toBe('/select-tenant/') + }) + + it('stashes the page asked for, so picking a tenant returns to it', async () => { + await run({ unresolvedTenant: { memberships: [] } }).catch(() => {}) + expect(stashRedirectTarget.mock.calls).toEqual([['http://localhost/pipelines/foo/']]) + }) + + it('fetches nothing while no tenant is resolved', async () => { + await run({ unresolvedTenant: { memberships: [] } }).catch(() => {}) + expect(loadDemos).not.toHaveBeenCalled() + }) +}) diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/+layout.ts b/js-packages/web-console/src/routes/(system)/(authenticated)/+layout.ts index df2596d2fcc..2b51e78c4a5 100644 --- a/js-packages/web-console/src/routes/(system)/(authenticated)/+layout.ts +++ b/js-packages/web-console/src/routes/(system)/(authenticated)/+layout.ts @@ -1,11 +1,13 @@ -import { loadDemos } from '$lib/compositions/useDemos.svelte' - +/** + * Being logged in is all this level requires, so `/select-tenant` lives here + * too. Anything that needs an acting tenant belongs in the `(authorized)` group, + * whose gate redirects here when none resolves. + */ export const load = async ({ parent }) => { const data = await parent() if (typeof data.auth === 'object' && 'login' in data.auth) { data.auth.login() await new Promise(() => {}) // Await indefinitely to avoid loading the page - until redirected to auth page } - loadDemos() return { ...data } } diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/select-tenant/+page.svelte b/js-packages/web-console/src/routes/(system)/(authenticated)/select-tenant/+page.svelte new file mode 100644 index 00000000000..e08953f4f70 --- /dev/null +++ b/js-packages/web-console/src/routes/(system)/(authenticated)/select-tenant/+page.svelte @@ -0,0 +1,16 @@ + + + +
+ + +
diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/select-tenant/+page.ts b/js-packages/web-console/src/routes/(system)/(authenticated)/select-tenant/+page.ts new file mode 100644 index 00000000000..1304c944f40 --- /dev/null +++ b/js-packages/web-console/src/routes/(system)/(authenticated)/select-tenant/+page.ts @@ -0,0 +1,17 @@ +import { redirect } from '@sveltejs/kit' +import { resolve } from '$lib/functions/svelte' +import { takeRedirectTarget } from '$lib/services/redirectTarget' + +/** + * The page the `(authorized)` gate redirects to. Reaching it with a tenant + * already resolved means there is nothing to pick — a stale history entry, or a + * second tab that picked first — so it hands the user on to wherever the gate + * interrupted them, consuming the stash so no later redirect reuses it. + */ +export const load = async ({ parent }) => { + const data = await parent() + if (!data.unresolvedTenant) { + throw redirect(307, takeRedirectTarget() ?? resolve('/')) + } + return { memberships: data.unresolvedTenant.memberships } +} diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/select-tenant/selectTenant.spec.ts b/js-packages/web-console/src/routes/(system)/(authenticated)/select-tenant/selectTenant.spec.ts new file mode 100644 index 00000000000..e519788ec62 --- /dev/null +++ b/js-packages/web-console/src/routes/(system)/(authenticated)/select-tenant/selectTenant.spec.ts @@ -0,0 +1,49 @@ +/** + * The tenant page sits outside the `(authorized)` group so it stays reachable + * while the gate is closed. Reaching it with a tenant already resolved means + * there is nothing to pick, so it hands the user back to whatever the gate + * interrupted. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const takeRedirectTarget = vi.hoisted(() => vi.fn()) + +vi.mock('$lib/services/redirectTarget', () => ({ + takeRedirectTarget: () => takeRedirectTarget() +})) +vi.mock('$lib/functions/svelte', () => ({ resolve: (path: string) => path })) + +import { load } from './+page' + +const run = (data: Record) => (load as any)({ parent: async () => data }) + +beforeEach(() => { + takeRedirectTarget.mockReset() +}) + +describe('/select-tenant', () => { + it('hands the memberships to the picker while none is resolved', async () => { + const memberships = [{ tenantId: 't-acme', name: 'acme', role: 'admin' }] + await expect(run({ unresolvedTenant: { memberships } })).resolves.toEqual({ memberships }) + expect(takeRedirectTarget).not.toHaveBeenCalled() + }) + + it('renders the no-access case as an empty list, not a redirect', async () => { + await expect(run({ unresolvedTenant: { memberships: [] } })).resolves.toEqual({ + memberships: [] + }) + }) + + it('returns to the interrupted page when a tenant is already resolved', async () => { + takeRedirectTarget.mockReturnValue('http://localhost/pipelines/foo/') + const thrown: any = await run({ feldera: { tenantId: 't-acme' } }).catch((e: any) => e) + expect(thrown.status).toBe(307) + expect(thrown.location).toBe('http://localhost/pipelines/foo/') + }) + + it('goes home when a tenant is resolved and nothing was interrupted', async () => { + takeRedirectTarget.mockReturnValue(undefined) + const thrown: any = await run({ feldera: { tenantId: 't-acme' } }).catch((e: any) => e) + expect(thrown.location).toBe('/') + }) +}) diff --git a/js-packages/web-console/src/routes/(system)/(authenticated)/select-tenant/selectTenantPage.svelte.spec.ts b/js-packages/web-console/src/routes/(system)/(authenticated)/select-tenant/selectTenantPage.svelte.spec.ts new file mode 100644 index 00000000000..37ce9255e79 --- /dev/null +++ b/js-packages/web-console/src/routes/(system)/(authenticated)/select-tenant/selectTenantPage.svelte.spec.ts @@ -0,0 +1,84 @@ +/** + * The tenant page renders the app header and the picker, nothing else: with no + * acting tenant there are no pipelines, banners or health to show. The header + * needs no special mode for this — its tenant-scoped items gate on permissions, + * which fall back to the read floor when the session reports no tenant, so what + * is left is the logo, the theme switch and sign-out. + */ +import { describe, expect, it, vi } from 'vitest' +import { page as browser } from 'vitest/browser' +import { render } from 'vitest-browser-svelte' + +vi.mock('$app/state', () => ({ + page: { + url: new URL('http://localhost/select-tenant/'), + data: { + // No `feldera`: this is exactly the state the page renders in. + auth: { + logout: vi.fn(), + profile: { name: 'Ada', email: 'ada@example.com' }, + userInfo: {}, + accessToken: '' + } + } + } +})) +vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() })) +vi.mock('$lib/compositions/switchTenant', () => ({ switchTenant: vi.fn() })) +// Both exports: `auth.ts` reaches this module too, through the header. +vi.mock('$lib/services/redirectTarget', () => ({ + takeRedirectTarget: vi.fn(), + stashRedirectTarget: vi.fn() +})) +// Dialog bodies the profile menu can open; they fetch on import and none of them +// is reachable in this state. +vi.mock('$lib/components/other/ApiKeyMenu.svelte', () => ({ default: () => {} })) +vi.mock('$lib/components/other/OidcTrustMenu.svelte', () => ({ default: () => {} })) + +// Imported AFTER vi.mock so the mocks take effect. +import SelectTenantPage from './+page.svelte' + +// The page reads only `memberships`; the rest of its PageData is merged-in +// parent layout data that plays no part in what renders here. +const data = { + memberships: [ + { tenantId: 't-acme', name: 'acme', role: 'admin' }, + { tenantId: 't-beta', name: 'beta', role: 'read' } + ] +} as any + +// The profile trigger's own label is width-gated, so identify it by its icon. +const profileTrigger = () => + document.querySelector('button:has(.fd-circle-user)') + +describe('/select-tenant page', () => { + it('shows the picker under the header, with no app chrome', async () => { + await render(SelectTenantPage, { data }) + + await expect.element(browser.getByRole('heading', { name: 'Choose a tenant' })).toBeVisible() + // The header: a logo linking home, and the profile trigger. + expect(document.querySelector('a[href="/"]')).not.toBeNull() + expect(profileTrigger()).not.toBeNull() + // None of the app shell: no create-pipeline drawer, banners or version notice. + expect(document.body.textContent).not.toContain('Create new pipeline') + expect(document.body.textContent).not.toContain('Book a demo') + }) + + it('offers the theme switch and sign-out, but nothing tenant-scoped', async () => { + await render(SelectTenantPage, { data }) + profileTrigger()!.click() + + await expect.poll(() => document.body.textContent).toContain('Sign Out') + expect(document.body.textContent).toContain('Theme') + // Permissions fall back to the read floor without a tenant, so every + // write-gated item hides itself and the page needs no special mode. + const menu = document.body.textContent ?? '' + expect(menu).not.toContain('Admin Dashboard') + expect(menu).not.toContain('Manage API keys') + expect(menu).not.toContain('Manage OIDC trust') + // Cluster health is read-role, so the read floor does NOT hide it: the entry + // gates on a resolved tenant instead. Without one it would show a status + // nothing polled and link into the gated group. + expect(menu).not.toContain('Feldera Health') + }) +}) diff --git a/js-packages/web-console/src/routes/+layout.ts b/js-packages/web-console/src/routes/+layout.ts index 68465336116..ac329ddfda0 100644 --- a/js-packages/web-console/src/routes/+layout.ts +++ b/js-packages/web-console/src/routes/+layout.ts @@ -2,7 +2,6 @@ import * as AxaOidc from '@axa-fr/oidc-client' import Dayjs from 'dayjs' import duration from 'dayjs/plugin/duration' import equal from 'fast-deep-equal' -import { jwtDecode } from 'jwt-decode' import posthog from 'posthog-js' import { goto, invalidateAll } from '$app/navigation' import { fromAxaUserInfo, toAxaOidcConfig } from '$lib/compositions/@axa-fr/auth' @@ -11,6 +10,7 @@ import { clearConfigCaches, configChanged, fetchConfigs, + getCachedConfigsForRender, getConfigFromCache, getSessionConfigFromCache } from '$lib/compositions/configCache' @@ -22,8 +22,7 @@ import { authRequestMiddleware, authResponseMiddleware, errorResponseMiddleware, - getSelectedTenant, - setSelectedTenant, + setSelectedTenantUser, triggerOidcLogin } from '$lib/services/auth' import { initConceptualHq } from '$lib/services/conceptualHq' @@ -31,8 +30,9 @@ import type { Configuration, SessionInfo } from '$lib/services/manager' import { client } from '$lib/services/manager/client.gen' import { initPosthog } from '$lib/services/posthog' import { initProductFruits } from '$lib/services/productFruits' -import { type Permission, permissionsOf, type Role, roleOf } from '$lib/services/rbac' -import type { AuthDetails } from '$lib/types/auth' +import { type Permission, permissionsOf, roleOf, type SessionRole } from '$lib/services/rbac' +import { takeRedirectTarget } from '$lib/services/redirectTarget' +import type { AuthDetails, TenantMembership } from '$lib/types/auth' import type { LayoutLoad } from './$types' Dayjs.extend(duration) @@ -80,6 +80,12 @@ export const trailingSlash = 'always' * - Unauthenticated: auth resolves to `{ login }` → empty layout is * returned; `(authenticated)/+layout.ts` triggers `auth.login()` and the * app redirects to the Identity Provider (IdP). + * - No acting tenant resolved (several memberships and no saved selection, + * or none at all): `fetchConfigs()` returns the session payload without a + * `Configuration`, and `load()` returns `unresolvedTenant`. The + * `(authorized)` group's gate turns that into a redirect to + * `/select-tenant`, so nothing below it ever loads or renders without a + * tenant. * * The warm-cache short-circuit is what keeps repeat visits from * double-fetching page-level resources, so be careful when adding new @@ -101,22 +107,33 @@ export type LayoutData = { tenantId: string tenantName: string /** - * Caller's RBAC role in the current tenant: read < write < admin < owner. + * Caller's RBAC role in the current tenant: read < write < admin < owner, + * or `NO_ROLE` when the session named none, which the server does exactly + * when it resolved no acting tenant. */ - role: Role + role: SessionRole /** * Permissions the role grants, materialized from the client role→permission - * map at init. + * map at init. Empty without a role. */ permissions: Permission[] /** - * Only available if authenticated and using multi-tenant authorization + * Tenants the logged-in user may act in, from the session's membership + * list. Empty for API keys, no-auth mode, and owners without explicit + * memberships. */ - authorizedTenants?: string[] + memberships: TenantMembership[] unstableFeatures: string[] config: Configuration } | undefined + /** + * Set when the login resolved no acting tenant: several memberships and no + * saved selection, or no memberships at all. Only /config/session answers in + * this state, so `feldera` is undefined; `/select-tenant` renders the picker + * (or the no-access notice) from this list. + */ + unresolvedTenant?: { memberships: TenantMembership[] } error?: Error } @@ -125,34 +142,12 @@ const emptyLayoutData: LayoutData = { feldera: undefined } -const computeAuthorizedTenants = (auth: AuthDetails): string[] | undefined => { - if (typeof auth !== 'object' || !('logout' in auth)) { - return undefined - } - const tenantsString = auth.accessToken - ? jwtDecode<{ tenants?: string[] | string }>(auth.accessToken).tenants - : undefined - return tenantsString - ? Array.isArray(tenantsString) - ? tenantsString - : tenantsString.split(',').map((t) => t.trim()) - : undefined -} - -const applyTenantSelection = (authorizedTenants: string[] | undefined) => { - // Only a claim-based authorized list clamps the selection (a multi-tenant - // user must land on a tenant the IdP actually authorizes). When there is no - // such list, e.g. a platform owner whose token carries no `tenants` claim, - // leave the saved selection untouched so the owner's "act as " choice - // persists across loads instead of being cleared on every navigation. - if (!authorizedTenants) { - return - } - const savedTenant = getSelectedTenant() - if (!savedTenant || !authorizedTenants.includes(savedTenant)) { - setSelectedTenant(authorizedTenants[0]) - } -} +const toMemberships = (sessionConfig: SessionInfo | undefined): TenantMembership[] => + (sessionConfig?.memberships ?? []).map((m) => ({ + tenantId: m.tenant_id, + name: m.name, + role: m.role + })) const pushSystemMessageOnce = (message: SystemMessage) => { if (!initSystemMessages.some((m) => m.id === message.id)) { @@ -239,11 +234,15 @@ const lazyUpdateConfig = async () => { console.warn('Background config refresh failed:', e) return } - if (!result.config) { - return + if (result.config) { + syncServerTimeFromConfig(result.config) } - syncServerTimeFromConfig(result.config) + // `config` may be undefined here: the session stopped resolving an acting + // tenant mid-session (e.g. this user was removed from the selected tenant). + // `configChanged` then reports a diff against the rendered cache, and the + // `invalidateAll()` re-runs `load()`, which lands in the unresolved-tenant + // branch, from where the `(authorized)` gate redirects to `/select-tenant`. if (!configChanged(prevConfig, result.config) && equal(prevSessionConfig, result.sessionConfig)) { return } @@ -273,17 +272,17 @@ const initAuth = async (): Promise => { oidcConfig: { ...toAxaOidcConfig(authConfig.oidc) }, logoutExtras: authConfig.logoutExtras, onAfterLogin: async () => { - const redirectTo = window.sessionStorage.getItem('redirect_to') + const redirectTo = takeRedirectTarget() if (!redirectTo) { return } - window.sessionStorage.removeItem('redirect_to') goto(redirectTo) }, onBeforeLogout() { - // Session-scoped data must not survive a logout/login cycle, since a different - // user may sign in on the same browser. - setSelectedTenant(undefined) + // Config caches must not survive a logout/login cycle, since a different + // user may sign in on the same browser. The tenant selection stays: it is + // keyed per user, so a returning user resumes their tenant without + // re-picking, and another user cannot inherit it. clearConfigCaches() posthog.reset() } @@ -318,8 +317,8 @@ const authInitPromise: Promise = initAuth() * * 2. **Config loading** — warm localStorage cache is the common path: * - **Warm cache (most navigations):** return cached `feldera` data - * synchronously, run idempotent side effects (tenant selection, - * posthog init, system messages), and kick off exactly one + * synchronously, run idempotent side effects (posthog init, system + * messages), and kick off exactly one * background `lazyUpdateConfig()` per session (guarded by * `lazyUpdateScheduled`). The UI renders immediately with cached * values; the background fetch only triggers an `invalidateAll()` @@ -357,10 +356,9 @@ export const load: LayoutLoad = async (): Promise => { } } - applyTenantSelection(computeAuthorizedTenants(auth)) - - const cachedConfig = OPTIMISTIC_CONFIG_CACHE ? getConfigFromCache() : undefined - const cachedSessionConfig = OPTIMISTIC_CONFIG_CACHE ? getSessionConfigFromCache() : undefined + const { config: cachedConfig, sessionConfig: cachedSessionConfig } = OPTIMISTIC_CONFIG_CACHE + ? getCachedConfigsForRender() + : {} if (cachedConfig) { initializeConfigDependencies(auth, cachedConfig) @@ -389,6 +387,17 @@ export const load: LayoutLoad = async (): Promise => { } if (!result.config) { + // No acting tenant resolved: /config answers 4xx in this state and only + // /config/session replies, so there is no Configuration to build the app + // shell from. The `(authorized)` gate redirects to `/select-tenant`, which + // renders the picker (or the no-access notice) from `unresolvedTenant`. + if (result.sessionConfig) { + return { + ...emptyLayoutData, + auth, + unresolvedTenant: { memberships: toMemberships(result.sessionConfig) } + } + } console.error('Failed to load configuration') return emptyLayoutData } @@ -399,12 +408,8 @@ export const load: LayoutLoad = async (): Promise => { return buildLayoutData(auth, result.config, result.sessionConfig) } -function buildFelderaData( - auth: AuthDetails, - config: Configuration, - sessionConfig: SessionInfo | undefined -) { - const role = roleOf(sessionConfig?.role) +function buildFelderaData(config: Configuration, sessionConfig: SessionInfo | undefined) { + const role = roleOf(sessionConfig?.role ?? undefined) return { version: config.version, edition: config.edition, @@ -417,15 +422,18 @@ function buildFelderaData( : undefined, changelog: config.changelog_url, revision: config.revision, + // The acting-tenant fields are null only in the unresolved-tenant session + // state, which `load()` diverts to `unresolvedTenant` before ever building + // `feldera`; the fallbacks below are for a missing session payload. tenantId: sessionConfig?.tenant_id || '', tenantName: sessionConfig?.tenant_name || '', - // `role` is added by the RBAC backend; the SDK type lags, so read it off the - // session payload and normalize any unexpected value to the least-privileged - // role. Permissions are materialized here once from the client - // role→permission map; owner-only UI gates on `write:tenant` (owner-only). + // Absent when the session named no role, which the server does exactly when + // it resolved no acting tenant. Permissions are materialized here once from + // the client role→permission map, and are empty in that case; owner-only UI + // gates on `write:tenant` (owner-only). role, permissions: permissionsOf(role), - authorizedTenants: computeAuthorizedTenants(auth), + memberships: toMemberships(sessionConfig), unstableFeatures: config.unstable_features?.split(',').map((f: string) => f.trim()) || [], config } @@ -438,7 +446,7 @@ function buildLayoutData( ): LayoutData { return { auth, - feldera: buildFelderaData(auth, config, sessionConfig) + feldera: buildFelderaData(config, sessionConfig) } } @@ -527,6 +535,10 @@ const axaOidcAuth = async (params: { } } + // The tenant selection is stored per user: key it to this login before + // the request interceptor below can attach a `Feldera-Tenant` header. + setSelectedTenantUser(userInfo.sub) + client.interceptors.request.use(authRequestMiddleware) client.interceptors.response.use(authResponseMiddleware) diff --git a/js-packages/web-console/web-console-permissions.md b/js-packages/web-console/web-console-permissions.md index 06f8e94aed3..306189885a7 100644 --- a/js-packages/web-console/web-console-permissions.md +++ b/js-packages/web-console/web-console-permissions.md @@ -32,6 +32,7 @@ not a rank comparison scattered across call sites. | `read:pipeline_code` | view SQL / UDF code | | `read:pipeline_config` | view runtime / program config, resources | | `read:support_bundle` | download support bundle, collect heap/samply/circuit profiles, diff | +| `read:cluster_health` | cluster monitor events, the health page, the header's health indicator | | `write:pipeline` | create, duplicate, import demo, delete | | `write:pipeline_code` | edit SQL / UDF Rust / UDF TOML | | `write:pipeline_config` | edit runtime config, compilation profile, resources | @@ -53,14 +54,15 @@ the source of truth in `src/lib/services/rbac.ts` (§5.1). | Role | Adds | | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `read` | `read:pipeline`, `read:pipeline_code`, `read:pipeline_config`, `read:support_bundle` | +| `read` | `read:pipeline`, `read:pipeline_code`, `read:pipeline_config`, `read:support_bundle`, `read:cluster_health` | | `write` | `write:pipeline`, `write:pipeline_code`, `write:pipeline_config`, `write:pipeline_meta`, `exec:pipeline`, `exec:checkpoint`, `exec:runtime_upgrade`, `exec:pipeline_data`, `write:api_key` | | `admin` | `write:tenant_member`, `write:oidc_trust` | | `owner` | `write:tenant`, `write:owner_trust` | Consequences for `read`: -- Sees every pipeline, its code, config, stats, logs. Reads are the floor, never gated. +- Sees every pipeline, its code, config, stats, logs. Reads are the floor within a + tenant, so they are gated only where a session without one could reach them. - Downloads support bundles and collects profiling data (all `read`-role on the backend). - No pipeline actions, no editing, no ad-hoc query, no API keys, no admin. @@ -114,9 +116,9 @@ init, so a `read` caller never lands on a blank panel. Because the tab is hidden ### 4.4 Demos (conditional for `read`) -| File | Behavior | -| ------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `other/DemoTile.svelte`, `compositions/pipelines/useTryPipeline.ts`, `routes/(system)/(authenticated)/demos/+page.svelte` | Enabled when a pipeline with the demo's name already exists (clicking navigates to it, allowed for `read`) OR the caller has `write:pipeline` (clicking creates it). Disable the tile only when neither holds. | +| File | Behavior | +| -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `other/DemoTile.svelte`, `compositions/pipelines/useTryPipeline.ts`, `routes/(system)/(authenticated)/(authorized)/demos/+page.svelte` | Enabled when a pipeline with the demo's name already exists (clicking navigates to it, allowed for `read`) OR the caller has `write:pipeline` (clicking creates it). Disable the tile only when neither holds. | This is the one gate that is not a plain permission check: `enabled = pipelineExists || has('write:pipeline')`. The header Create Pipeline button stays a plain `write:pipeline` hide. @@ -125,10 +127,11 @@ This is the one gate that is not a plain permission check: `enabled = pipelineEx | File | Permission | Min role | Status | | ----------------------------------------------------------------------------------------------- | --------------------- | -------- | ------------------------------------------------------------------------------------------------ | | `auth/ProfileButton.svelte`, `other/ApiKeyMenu.svelte`, `apiKey/NewApiKeyForm.svelte` | `write:api_key` | write | menu already gated; migrate its ad-hoc role check to `write:api_key` | -| `routes/(system)/(authenticated)/admin/+page.ts` | `write:tenant_member` | admin | already redirects; express via permission | +| `routes/(system)/(authenticated)/(authorized)/admin/+page.ts` | `write:tenant_member` | admin | already redirects; express via permission | | `admin/UserRoleTable.svelte` | `write:tenant_member` | admin | in admin area | | `other/OidcTrustMenu.svelte`, `oidcTrust/NewOidcTrustForm.svelte` | `write:oidc_trust` | admin | in gated menu | | `admin/TenantList.svelte`, `admin/AdminPage.svelte` (tenant switcher, Platform owners, Tenants) | `write:tenant` | owner | gated on `write:tenant`; owner-trust CRUD removed (owner is deploy-time config, shown read-only) | +| `auth/ProfileButton.svelte` (Feldera Health entry) | `read:cluster_health` | read | the one read gate: this header also renders on `/select-tenant`, where a session holds nothing | Within `NewApiKeyForm.svelte`, both `read` and `write` key options stay offered: everyone who can open the menu already holds `write:api_key`, so the old @@ -139,7 +142,10 @@ everyone who can open the menu already holds `write:api_key`, so the old `auth/CurrentTenant.svelte` (tenant switch), `auth/ProfileButton.svelte` Sign Out, `other/AuthErrorToast.svelte` (re-auth), `layout/userPopup/DarkModeSwitch.svelte`, `layout/pipelines/EditorOptionsPopup.svelte`, `layout/{Drawer,InlineDrawer}.svelte`, -`profile-viewer/+page.svelte`, `version/**`, `health/**`. +`profile-viewer/+page.svelte`, `version/**`. The `/health` page itself carries no +gate: it sits in the `(authorized)` route group, so reaching it already means a +tenant resolved, and every role holds `read:cluster_health`. The header entry +linking to it is gated, since that header also renders on `/select-tenant`. ## 5. Technical design @@ -160,6 +166,7 @@ export type Permission = | 'read:pipeline_code' | 'read:pipeline_config' | 'read:support_bundle' + | 'read:cluster_health' | 'write:pipeline' | 'write:pipeline_code' | 'write:pipeline_config' @@ -178,7 +185,13 @@ export type Permission = const ROLES: Role[] = ['read', 'write', 'admin', 'owner'] const GRANTS: Record = { - read: ['read:pipeline', 'read:pipeline_code', 'read:pipeline_config', 'read:support_bundle'], + read: [ + 'read:pipeline', + 'read:pipeline_code', + 'read:pipeline_config', + 'read:support_bundle', + 'read:cluster_health' + ], write: [ 'write:pipeline', 'write:pipeline_code', @@ -208,22 +221,41 @@ const PERMISSIONS: Record> = (() => { export const hasPermission = (role: Role, permission: Permission): boolean => PERMISSIONS[role].has(permission) -// The permissions a role grants, as a plain array. `+layout.ts` materializes +// What a session reports when it holds no role. A named case rather than +// `undefined`, so `page.data.feldera.role` is total and a consumer that forgets +// this case gets a type error. `Role` stays the four roles the backend grants. +export const NO_ROLE = 'no_role' +export type SessionRole = Role | typeof NO_ROLE + +// The permissions a role grants, and none for NO_ROLE. `+layout.ts` materializes // this into `page.data.feldera.permissions` at session-config init. -export const permissionsOf = (role: Role): Permission[] => [...PERMISSIONS[role]] +export const permissionsOf = (role: SessionRole): Permission[] => + role === NO_ROLE ? [] : [...PERMISSIONS[role]] -// Fallback for a session whose config is not present yet. -export const DEFAULT_PERMISSIONS: readonly Permission[] = Object.freeze(permissionsOf('read')) +// The role a session holds, or NO_ROLE. The server sends `role: null` exactly +// when no acting tenant resolved, since a role is granted per membership. An +// unrecognized role reads the same way, so a backend role the map has not caught +// up with grants nothing. +export const roleOf = (role: string | null | undefined): SessionRole => + (ROLES as string[]).includes(role ?? '') ? (role as Role) : NO_ROLE -// Session-facing check: reads the materialized list off `page.data.feldera` and -// falls back to the read floor when it is absent, so gates never crash and never -// leak write access before the session loads. +export const NO_PERMISSIONS: readonly Permission[] = Object.freeze([]) + +// Session-facing check: reads the materialized list off `page.data.feldera`. An +// absent `feldera` means no session resolved a tenant, and grants nothing, so +// gates deny by default the way the backend's route table does. export const hasPermissions = ( feldera: { permissions: readonly Permission[] } | undefined, permission: Permission -): boolean => (feldera?.permissions ?? DEFAULT_PERMISSIONS).includes(permission) +): boolean => (feldera?.permissions ?? NO_PERMISSIONS).includes(permission) ``` +A `NO_ROLE` session holding no permissions is what lets every gate be a plain +``. The alternative, reporting `read` for a missing role, would make read +gates useless: the header renders on `/select-tenant`, where a read-role +permission would be held by a session that cannot call a single tenant-scoped +route. + `+layout.ts` normalizes the role once with `roleOf` and injects the granted permissions, so the role to permission map is applied at the single boundary where session data enters, and every gate reads a data field: diff --git a/openapi.json b/openapi.json index cc5023ca430..16682bd1c3e 100644 --- a/openapi.json +++ b/openapi.json @@ -534,7 +534,7 @@ "Platform" ], "summary": "Get Session", - "description": "Required role: `read` or higher.\n\nRetrieve login session information for your current user session.", + "description": "Required role: `read` or higher.\n\nRetrieve login session information for your current user session.\n\nThis is the one route that answers a login without a resolved acting\ntenant: when the user belongs to several tenants (or none) and no\n`Feldera-Tenant` header selects one, the acting-tenant fields are `null`\nand `memberships` lists the tenants to pick from.", "operationId": "get_config_session", "responses": { "200": { @@ -7453,7 +7453,7 @@ "Platform" ], "summary": "Provision Tenant Member", - "description": "Required role: `admin` or higher.\n\nAdd a member to the acting tenant by identity, before the user's first\nlogin. The grant is dormant until that identity authenticates into the\ntenant through the IdP. The role is capped at the caller's own role and may\nnot be `owner`.", + "description": "Required role: `admin` or higher.\n\nAdd a member to the acting tenant by identity, before the user's first\nlogin. The membership authorizes on its own: as soon as that identity\nauthenticates through the platform's identity provider, the user may act\nin this tenant, and a headerless login with exactly this one membership\nlands in it. The role is capped at the caller's own role and may not be\n`owner`.", "operationId": "add_tenant_user", "requestBody": { "content": { @@ -7690,7 +7690,7 @@ "Platform" ], "summary": "Create Tenant", - "description": "Required role: `owner`.\n\nExplicitly create a tenant, rather than relying on first login.\nA login resolves its tenant by name, so a user whose identity provider\nasserts this name lands in the tenant created here. Fails with a conflict if\nthe name is already taken.", + "description": "Required role: `owner`.\n\nExplicitly create a tenant, rather than relying on first login.\nA login resolves its tenant by name, so a user whose identity provider\nasserts this name lands in the tenant created here.", "operationId": "create_tenant", "requestBody": { "content": { @@ -7703,12 +7703,22 @@ "required": true }, "responses": { + "200": { + "description": "Tenant with that name already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TenantInfo" + } + } + } + }, "201": { "description": "Tenant created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NewTenantResponse" + "$ref": "#/components/schemas/TenantInfo" } } } @@ -7723,8 +7733,66 @@ } } }, - "409": { - "description": "A tenant with that name already exists", + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "JSON web token (JWT) or API key": [] + } + ] + } + }, + "/v0/tenants/{tenant_id}": { + "get": { + "tags": [ + "Platform" + ], + "summary": "Get Tenant", + "description": "Required role: `owner`.\n\nRetrieve a single tenant by name or identifier. A selector that parses as a\nUUID is looked up by tenant identifier, otherwise by name.", + "operationId": "get_tenant", + "parameters": [ + { + "name": "tenant_id", + "in": "path", + "description": "Tenant name or identifier (UUID)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tenant retrieved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TenantInfo" + } + } + } + }, + "403": { + "description": "Caller is not a platform owner", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "No tenant with that name or identifier", "content": { "application/json": { "schema": { @@ -7749,9 +7817,7 @@ "JSON web token (JWT) or API key": [] } ] - } - }, - "/v0/tenants/{tenant_id}": { + }, "delete": { "tags": [ "Platform" @@ -11555,6 +11621,15 @@ "admin" ] }, + "MembershipOrigin": { + "type": "string", + "description": "How a membership row came into existence, kept for audit.", + "enum": [ + "claim", + "derived", + "api" + ] + }, "MemoryPressure": { "type": "string", "description": "Memory pressure level.\n\nThe current memory pressure level is computed as a function of the current process\nresident set size (RSS) and the user-configured memory limit (`max_rss`).\n\nAs the memory pressure level increases, the system will apply increasing backpressure to\npush state cached in memory to storage.\n\n- `Low`: less than 85% of the user-configured memory limit has been allocated.\n- `Moderate`: between 85% and 90% of the user-configured memory limit has been allocated.\n- `High`: between 90% and 95% of the user-configured memory limit has been allocated.\n- `Critical`: more than 95% of the user-configured memory limit has been allocated.", @@ -11845,22 +11920,6 @@ } } }, - "NewTenantResponse": { - "type": "object", - "description": "Response to a successful tenant creation.", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "$ref": "#/components/schemas/TenantId" - }, - "name": { - "type": "string" - } - } - }, "NexmarkInputConfig": { "type": "object", "description": "Configuration for generating Nexmark input data.\n\nThis connector must be used exactly three times in a pipeline if it is used\nat all, once for each [`NexmarkTable`].", @@ -14789,20 +14848,36 @@ "SessionInfo": { "type": "object", "required": [ - "tenant_id", - "tenant_name", - "role" + "memberships" ], "properties": { + "memberships": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserMembership" + }, + "description": "The tenants this user may act in, from the membership table. Empty for\nprincipals that are not human logins (API keys, federated workloads,\nno-auth mode) and for platform owners without explicit memberships,\nwho act in any tenant regardless." + }, "role": { - "$ref": "#/components/schemas/Role" + "allOf": [ + { + "$ref": "#/components/schemas/Role" + } + ], + "nullable": true }, "tenant_id": { - "$ref": "#/components/schemas/TenantId" + "allOf": [ + { + "$ref": "#/components/schemas/TenantId" + } + ], + "nullable": true }, "tenant_name": { "type": "string", - "description": "Current user's tenant name" + "description": "Acting tenant name; `null` exactly when `tenant_id` is.", + "nullable": true } } }, @@ -15395,7 +15470,7 @@ }, "TenantInfo": { "type": "object", - "description": "A tenant, as returned by the platform (owner-only) tenant list.", + "description": "A tenant, as returned by the owner-only tenant endpoints.", "required": [ "id", "name", @@ -15424,9 +15499,26 @@ "role" ], "properties": { + "display_name": { + "type": "string", + "description": "The member's name, as the identity provider spells it; `null` until the\nprovider has been asked.", + "nullable": true + }, "email": { "type": "string", - "description": "Email, if the identity provider supplied one.", + "description": "Email, if the identity provider supplied one or an administrator\nrecorded one when pre-provisioning the membership.", + "nullable": true + }, + "email_verified": { + "type": "boolean", + "description": "Whether the identity provider vouches for `email`. False until the\nprovider has been asked, and for providers that say nothing either way.\nAn email an administrator typed is never verified, so this is what\nseparates an address the provider stands behind from a claim about one." + }, + "origin": { + "allOf": [ + { + "$ref": "#/components/schemas/MembershipOrigin" + } + ], "nullable": true }, "provider": { @@ -16011,6 +16103,27 @@ "format": "uuid", "description": "Identifier of a persisted user (the principal behind an OIDC `sub`)." }, + "UserMembership": { + "type": "object", + "description": "One tenant a user may act in, as surfaced to that user (e.g. in the\nsession payload that drives the web console's tenant switcher).", + "required": [ + "tenant_id", + "name", + "role" + ], + "properties": { + "name": { + "type": "string", + "description": "The tenant's name." + }, + "role": { + "$ref": "#/components/schemas/Role" + }, + "tenant_id": { + "$ref": "#/components/schemas/TenantId" + } + } + }, "ValidateProgramRequest": { "type": "object", "description": "Request body for the program validation endpoint.", diff --git a/python/feldera/rest/_httprequests.py b/python/feldera/rest/_httprequests.py index 077c055558f..32a7c08b056 100644 --- a/python/feldera/rest/_httprequests.py +++ b/python/feldera/rest/_httprequests.py @@ -20,6 +20,7 @@ Retrying, retry_if_exception, stop_after_attempt, + stop_after_delay, wait_exponential, ) @@ -196,6 +197,7 @@ def send_request( params: Optional[Mapping[str, Any]] = None, stream: bool = False, serialize: bool = True, + idempotent: Optional[bool] = None, ) -> Any: """ :param http_method: The HTTP method to use. Takes the equivalent `requests.*` module. (Example: `requests.get`) @@ -205,6 +207,10 @@ def send_request( :param params: The query parameters part of this request. :param stream: True if the response is expected to be a HTTP stream. :param serialize: True if the body needs to be serialized to JSON. + :param idempotent: Overrides the by-method idempotency assumption + (only GET is assumed idempotent). Callers whose endpoint is + idempotent by API contract, such as a desired-state setter, + pass True so a dropped connection retries. Send an HTTP request, retrying transient failures per the client's `RetryConfig`. @@ -212,10 +218,15 @@ def send_request( Retry policy: - Status codes in `retry_config.retryable_status_codes` (default 408, 429, 502, 503, 504) and connection/read timeouts retry. - - For GET, a `ConnectionError` (e.g. connection reset mid-request) - also retries, since a lost response can't have caused a - server-side side effect. Not retried for POST/PUT/PATCH/DELETE, - where the original request may already have been applied. + - For GET, and for any request marked `idempotent=True`, a + `ConnectionError` (e.g. connection reset mid-request) also + retries: a lost response can't change the outcome of an + idempotent request. Other POST/PUT/PATCH/DELETE requests do not + retry it, since the original request may already have been + applied. + - A DELETE marked `idempotent=True` that receives 404 on a retry + attempt reports success: the resource vanished between attempts, + so an earlier attempt was applied and the postcondition holds. - 502 probes `/cluster_healthz` to distinguish a spurious gateway error (cluster healthy → retry immediately) from a real outage (cluster unhealthy → wait `unhealthy_backoff` seconds before @@ -223,9 +234,14 @@ def send_request( - Other retryable failures use exponential backoff with optional jitter; a server-supplied `Retry-After` header overrides it (capped at `max_backoff`). + - Retrying stops after `max_retries` retries, or, when + `retry_config.deadline_seconds` is set, once that wall-clock + budget is spent (the attempt count is then unbounded). - All other errors are raised immediately. """ - is_idempotent = http_method is requests.get + is_idempotent = ( + (http_method is requests.get) if idempotent is None else idempotent + ) request_path = self.config.url + "/" + self.config.version + path # Serialize the body once, not per retry. None / bytes / `serialize=False` @@ -247,21 +263,42 @@ def send_request( ) cfg = self.config.retry_config + # A wall-clock deadline (when configured) replaces the attempt cap: + # transient outages last for a duration, not a number of requests. + stop = ( + stop_after_delay(cfg.deadline_seconds) + if cfg.deadline_seconds is not None + else stop_after_attempt(cfg.max_retries + 1) + ) retryer = Retrying( retry=retry_if_exception( lambda exc: self._is_retryable(exc, is_idempotent) ), wait=self._custom_wait, - stop=stop_after_attempt(cfg.max_retries + 1), + stop=stop, reraise=True, ) try: for attempt in retryer: with attempt: - return self._do_single_request( - http_method, request_path, data, params, stream, headers - ) + try: + return self._do_single_request( + http_method, request_path, data, params, stream, headers + ) + except FelderaAPIError as err: + if ( + is_idempotent + and http_method is requests.delete + and err.status_code == 404 + and attempt.retry_state.attempt_number > 1 + ): + # The resource vanished between attempts: an + # earlier attempt was applied server-side even + # though its response was lost. The postcondition + # (resource absent) holds, so report success. + return None + raise except FelderaAPIError as err: # On 401, if the bearer is a callable, re-resolve once and retry. # Covers tokens that expire mid-flight in long-running scripts @@ -302,6 +339,7 @@ def post( params: Optional[Mapping[str, Any]] = None, stream: bool = False, serialize: bool = True, + idempotent: Optional[bool] = None, ) -> Any: return self.send_request( requests.post, @@ -311,6 +349,7 @@ def post( params, stream=stream, serialize=serialize, + idempotent=idempotent, ) def patch( @@ -332,8 +371,11 @@ def put( ] = None, content_type: str = "application/json", params: Optional[Mapping[str, Any]] = None, + idempotent: Optional[bool] = None, ) -> Any: - return self.send_request(requests.put, path, body, content_type, params) + return self.send_request( + requests.put, path, body, content_type, params, idempotent=idempotent + ) def delete( self, @@ -342,8 +384,11 @@ def delete( Union[Mapping[str, Any], Sequence[Mapping[str, Any]], List[str]] ] = None, params: Optional[Mapping[str, Any]] = None, + idempotent: Optional[bool] = None, ) -> Any: - return self.send_request(requests.delete, path, body, params=params) + return self.send_request( + requests.delete, path, body, params=params, idempotent=idempotent + ) @staticmethod def __to_json(request: requests.Response) -> Any: diff --git a/python/feldera/rest/feldera_client.py b/python/feldera/rest/feldera_client.py index fce56cf99f5..5f9c1aa25bb 100644 --- a/python/feldera/rest/feldera_client.py +++ b/python/feldera/rest/feldera_client.py @@ -260,10 +260,13 @@ def __wait_for_pipeline_state( start_time = time.monotonic() wait_for_state = LongOperationWarning( logger, - lambda elapsed: f"still waiting for {pipeline_name} to transition to " - f"'{state}', waited {elapsed:.1f} seconds", - lambda elapsed: f"{pipeline_name} transitioned to '{state}' after " - f"{elapsed:.1f} seconds", + lambda elapsed: ( + f"still waiting for {pipeline_name} to transition to " + f"'{state}', waited {elapsed:.1f} seconds" + ), + lambda elapsed: ( + f"{pipeline_name} transitioned to '{state}' after {elapsed:.1f} seconds" + ), ) while True: @@ -308,8 +311,10 @@ def __wait_for_pipeline_state_one_of( states = [state.lower() for state in states] wait_for_states = LongOperationWarning( logger, - lambda elapsed: f"still waiting for {pipeline_name} to transition to " - f"one of {states}, waited {elapsed:.1f} seconds", + lambda elapsed: ( + f"still waiting for {pipeline_name} to transition to " + f"one of {states}, waited {elapsed:.1f} seconds" + ), lambda elapsed: f"{pipeline_name} transitioned after {elapsed:.1f} seconds", ) @@ -394,9 +399,12 @@ def create_or_update_pipeline( "tags": _normalize_tags(pipeline.tags), } + # Upsert by name: applying the same body twice yields the same + # pipeline, so a dropped connection is safe to retry. self.http.put( path=f"/pipelines/{pipeline.name}", body=body, + idempotent=True, ) if not wait: @@ -466,6 +474,7 @@ def delete_pipeline(self, name: str): """ self.http.delete( path=f"/pipelines/{name}", + idempotent=True, ) def get_pipeline_stats(self, name: str) -> dict: @@ -509,8 +518,11 @@ def activate_pipeline( pipeline to activate. """ + # Desired-state setter: activating an already-activating pipeline is + # a no-op, so a dropped connection is safe to retry. self.http.post( path=f"/pipelines/{pipeline_name}/activate", + idempotent=True, ) if not wait: @@ -547,7 +559,9 @@ def _inner_start_pipeline( # Any error is dismissed separately in order to make sure we only dismiss any error # BEFORE it was started, not after it has been started by this call if dismiss_error: - self.http.post(path=f"/pipelines/{pipeline_name}/dismiss_error") + self.http.post( + path=f"/pipelines/{pipeline_name}/dismiss_error", idempotent=True + ) start_params["dismiss_error"] = "false" start_params["initial"] = initial @@ -560,9 +574,13 @@ def _inner_start_pipeline( if concurrent_bootstrap: start_params["concurrent_bootstrap"] = "true" + # Desired-state setters (start/pause/stop): repeating the request + # yields the same desired state, so a dropped connection is safe to + # retry. self.http.post( path=f"/pipelines/{pipeline_name}/start", params=start_params, + idempotent=True, ) if not wait: @@ -711,6 +729,7 @@ def pause_pipeline( self.http.post( path=f"/pipelines/{pipeline_name}/pause", + idempotent=True, ) if not wait: @@ -771,6 +790,7 @@ def stop_pipeline( self.http.post( path=f"/pipelines/{pipeline_name}/stop", params=params, + idempotent=True, ) if not wait: @@ -779,7 +799,9 @@ def stop_pipeline( start = time.monotonic() wait_for_stop = LongOperationWarning( logger, - lambda elapsed: f"still stopping {pipeline_name}, waited {elapsed:.1f} seconds", + lambda elapsed: ( + f"still stopping {pipeline_name}, waited {elapsed:.1f} seconds" + ), lambda elapsed: f"{pipeline_name} stopped after {elapsed:.1f} seconds", ) @@ -840,8 +862,12 @@ def clear_storage( start = time.monotonic() wait_for_clear = LongOperationWarning( logger, - lambda elapsed: f"still clearing {pipeline_name}, waited {elapsed:.1f} seconds", - lambda elapsed: f"{pipeline_name} storage cleared after {elapsed:.1f} seconds", + lambda elapsed: ( + f"still clearing {pipeline_name}, waited {elapsed:.1f} seconds" + ), + lambda elapsed: ( + f"{pipeline_name} storage cleared after {elapsed:.1f} seconds" + ), ) while True: if timeout_s is not None and time.monotonic() - start > timeout_s: @@ -1054,10 +1080,14 @@ def commit_transaction( wait_for_commit = LongOperationWarning( logger, - lambda elapsed: f"transaction {transaction_id} on {pipeline_name} " - f"hasn't committed, waited {elapsed:.1f} seconds", - lambda elapsed: f"transaction {transaction_id} on {pipeline_name} " - f"committed after {elapsed:.1f} seconds", + lambda elapsed: ( + f"transaction {transaction_id} on {pipeline_name} " + f"hasn't committed, waited {elapsed:.1f} seconds" + ), + lambda elapsed: ( + f"transaction {transaction_id} on {pipeline_name} " + f"committed after {elapsed:.1f} seconds" + ), ) while True: if timeout_s is not None: @@ -1285,10 +1315,13 @@ def wait_for_token( retries = 0 wait_for_token_processed = LongOperationWarning( logger, - lambda elapsed: f"still waiting for inputs represented by {token} " - f"to be processed, waited {elapsed:.1f} seconds", - lambda elapsed: f"inputs represented by {token} processed after " - f"{elapsed:.1f} seconds", + lambda elapsed: ( + f"still waiting for inputs represented by {token} " + f"to be processed, waited {elapsed:.1f} seconds" + ), + lambda elapsed: ( + f"inputs represented by {token} processed after {elapsed:.1f} seconds" + ), ) while True: @@ -1588,6 +1621,7 @@ def pause_connector(self, pipeline_name, table_name, connector_name): self.http.post( path=f"/pipelines/{pipeline_name}/tables/{table_name}/connectors/{connector_name}/pause", + idempotent=True, ) def resume_connector( @@ -1612,6 +1646,7 @@ def resume_connector( self.http.post( path=f"/pipelines/{pipeline_name}/tables/{table_name}/connectors/{connector_name}/start", + idempotent=True, ) def get_config(self) -> FelderaConfig: @@ -1659,6 +1694,38 @@ def list_tenants(self) -> List[dict]: """ return self.http.get(path="/tenants") + def get_tenant(self, tenant: str) -> dict: + """ + Retrieve a single tenant by name or identifier. Platform owners only. + + A selector that parses as a UUID is looked up by tenant identifier, + otherwise by name. + + :param tenant: The tenant's name or identifier (UUID). + :returns: A dict describing the tenant (`id`, `name`, + `initial_provider`). + :raises FelderaAPIError: If no tenant matches the selector. + """ + if not tenant: + raise ValueError("Tenant selector must be a non-empty string") + return self.http.get(path=f"/tenants/{quote(tenant, safe='')}") + + def create_tenant(self, name: str) -> dict: + """ + Create a tenant, or return it if one with this name already exists. + Platform owners only. + + A login resolves its tenant by name, so a user whose identity provider + asserts this name lands in the tenant created here. + + :param name: The name of the tenant. + :returns: A dict describing the tenant (`id`, `name`, + `initial_provider`). + """ + if not name: + raise ValueError("Tenant name must be a non-empty string") + return self.http.post(path="/tenants", body={"name": name}) + def rename_tenant( self, tenant_id: str, name: str, displace_existing: bool = False ) -> dict: diff --git a/python/feldera/rest/retry.py b/python/feldera/rest/retry.py index b2977042477..c54a0dfb260 100644 --- a/python/feldera/rest/retry.py +++ b/python/feldera/rest/retry.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import FrozenSet +from typing import FrozenSet, Optional _DEFAULT_RETRYABLE_STATUS_CODES: FrozenSet[int] = frozenset({408, 429, 502, 503, 504}) @@ -29,7 +29,13 @@ class RetryConfig: :param max_retries: Number of retries to attempt after the initial request. A value of `3` means up to `4` total attempts. Must be `>= 0`. - Default: `3`. + Ignored when `deadline_seconds` is set. Default: `3`. + :param deadline_seconds: Wall-clock retry budget in seconds, measured from + the first attempt. When set, `max_retries` no longer applies: retries + continue (with the usual waits) until the budget is exhausted. + Transient outages such as a node replacement last for a duration, not + a number of requests, so a duration budget survives them where an + attempt cap gives up early. Default: `None` (attempt-based stopping). :param initial_backoff: Base wait in seconds before the first retry. Default: `2.0`. :param max_backoff: Maximum wait in seconds between retries. The computed @@ -49,6 +55,7 @@ class RetryConfig: """ max_retries: int = 3 + deadline_seconds: Optional[float] = None initial_backoff: float = 2.0 max_backoff: float = 64.0 multiplier: float = 2.0 @@ -61,6 +68,8 @@ class RetryConfig: def __post_init__(self) -> None: if self.max_retries < 0: raise ValueError("max_retries must be >= 0") + if self.deadline_seconds is not None and self.deadline_seconds <= 0: + raise ValueError("deadline_seconds must be > 0") if self.initial_backoff < 0: raise ValueError("initial_backoff must be >= 0") if self.max_backoff < 0: diff --git a/python/feldera/testutils.py b/python/feldera/testutils.py index 1d4b56593ea..adfbcc2ecf3 100644 --- a/python/feldera/testutils.py +++ b/python/feldera/testutils.py @@ -20,7 +20,7 @@ from feldera.pipeline import Pipeline from feldera.pipeline_builder import PipelineBuilder from feldera.runtime_config import Resources, RuntimeConfig -from feldera.rest import FelderaClient +from feldera.rest import FelderaClient, RetryConfig from feldera.rest._helpers import requests_verify_from_env logger = logging.getLogger(__name__) @@ -190,6 +190,18 @@ def _ensure(self): # 401. Elsewhere the credential is fixed for the process. self._client = FelderaClient( connection_timeout=10, + # Shared CI instances see infrastructure churn (node + # replacement, pipeline pod rescheduling) that outlasts the + # default attempt-based retry budget of ~14 seconds. Retry on + # a wall-clock budget sized to ride out a node replacement. + # CI only: `enterprise_only` gates call `get_config` at + # import time, and a 5-minute retry against an instance that + # is not running would hang local test collection. + retry_config=( + RetryConfig(deadline_seconds=300.0) + if os.environ.get("CI") + else RetryConfig() + ), api_key=( feldera_bearer_token if os.environ.get("ACTIONS_ID_TOKEN_REQUEST_URL") diff --git a/python/pyproject.toml b/python/pyproject.toml index eb84709748e..470f1aa8936 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -40,6 +40,7 @@ Issues = "https://github.com/feldera/feldera/issues" [dependency-groups] dev = [ + "pytest-rerunfailures>=15", "pytest-timeout>=2.3.1", "pytest-xdist>=3.8.0", "pytest>=8.3.5", @@ -54,6 +55,19 @@ dev = [ [tool.pytest.ini_options] pythonpath = "feldera" +# Rerun tests that die on infra-shaped errors: the shared CI instances see +# node replacement and pipeline pod rescheduling that no request-level retry +# can fully absorb. The allowlist keeps reruns to transport failures and +# pipeline unavailability; assertion failures never match, so real bugs +# still fail deterministically. +addopts = [ + "--reruns=2", + "--only-rerun=FelderaCommunicationError", + "--only-rerun=PipelineUnavailable", + "--only-rerun=ChunkedEncodingError", + "--only-rerun=ProtocolError", + "--only-rerun=ArrowInvalid", +] [tool.setuptools.packages.find] include = ["feldera", "feldera.*"] diff --git a/python/tests/platform_rbac/conftest.py b/python/tests/platform_rbac/conftest.py index af9dc9dccc5..cd083f43853 100644 --- a/python/tests/platform_rbac/conftest.py +++ b/python/tests/platform_rbac/conftest.py @@ -196,6 +196,16 @@ def multi_tenant_auth( return AuthConfig(name="multi-tenant", env=config.env) +def membership_auth(idp: Issuer, owners: str = OWNER_EMAIL) -> AuthConfig: + """Authenticated with login provisioning off: memberships are the only + authority, and rows come solely from the RBAC and tenant endpoints.""" + config = single_tenant_auth(idp, None, owners) + env = dict(config.env) + env["FELDERA_AUTH_PROVISION_ON_LOGIN"] = "false" + env["FELDERA_AUTH_INDIVIDUAL_TENANT"] = "false" + return AuthConfig(name="membership", env=env) + + # Talking to the manager class Api: """Raw REST against the manager. diff --git a/python/tests/platform_rbac/test_1_scenarios.py b/python/tests/platform_rbac/test_1_scenarios.py index 99765fbbdef..91537fc4e25 100644 --- a/python/tests/platform_rbac/test_1_scenarios.py +++ b/python/tests/platform_rbac/test_1_scenarios.py @@ -341,6 +341,43 @@ def test_07_tenant_deletion_requires_emptiness(api: Api, primary_idp: Issuer): assert TENANT in remaining +# Tenant lookup and idempotent creation +def test_07b_tenant_lookup_and_idempotent_create(api: Api, primary_idp: Issuer): + """Creating an existing tenant returns it instead of conflicting, and a + single tenant is retrievable by name or identifier without listing all.""" + owner = primary_idp.token("owner", email=OWNER_EMAIL) + created = api.v0("POST", "/tenants", token=owner, body={"name": "gamma"}) + assert created.status_code == 201, created.text + gamma_id = created.json()["id"] + + # Repeating the create returns the existing tenant rather than a conflict. + repeat = api.v0("POST", "/tenants", token=owner, body={"name": "gamma"}) + assert repeat.status_code == 200, repeat.text + assert repeat.json()["id"] == gamma_id + + by_name = api.v0("GET", "/tenants/gamma", token=owner) + assert by_name.status_code == 200, by_name.text + assert by_name.json()["id"] == gamma_id + by_id = api.v0("GET", f"/tenants/{gamma_id}", token=owner) + assert by_id.status_code == 200, by_id.text + assert by_id.json()["name"] == "gamma" + assert api.status("GET", "/tenants/no-such-tenant", token=owner) == 404 + + # A UUID selector resolves by identifier only: a tenant named a UUID string + # is not retrievable through that name, only through its own identifier. + uuid_name = "11111111-2222-3333-4444-555555555555" + named = api.v0("POST", "/tenants", token=owner, body={"name": uuid_name}) + assert named.status_code == 201, named.text + named_id = named.json()["id"] + assert api.status("GET", f"/tenants/{uuid_name}", token=owner) == 404 + by_own_id = api.v0("GET", f"/tenants/{named_id}", token=owner) + assert by_own_id.json()["name"] == uuid_name + + # Leave no trace for the scenarios that follow. + assert api.status("DELETE", f"/tenants/{named_id}", token=owner) == 200 + assert api.status("DELETE", f"/tenants/{gamma_id}", token=owner) == 200 + + # Multi-tenant tokens def test_08_one_subject_holds_a_role_per_tenant( manager: Manager, api: Api, primary_idp: Issuer, workload_idp: Issuer diff --git a/python/tests/platform_rbac/test_6_membership.py b/python/tests/platform_rbac/test_6_membership.py new file mode 100644 index 00000000000..85a9e961bce --- /dev/null +++ b/python/tests/platform_rbac/test_6_membership.py @@ -0,0 +1,164 @@ +"""Membership-driven authorization, with and without login provisioning. + +The membership table authorizes every login; the tenancy strategy only +provisions rows, and only while provision-on-login is enabled. These scenarios +pin the union semantics under the default flag, deny-by-default with the flag +off, and the selector behavior shared by both. The module runs in order. +""" + +from __future__ import annotations + +import pytest + +from .conftest import OWNER_EMAIL, Api, membership_auth, multi_tenant_auth +from .idp import Issuer +from .manager import Manager + +pytestmark = pytest.mark.rbac + +M_ALPHA = "m-alpha" +M_BETA = "m-beta" +M_GAMMA = "m-gamma" + + +def test_01_membership_reaches_beyond_the_claim( + manager: Manager, api: Api, primary_idp: Issuer +): + """With provisioning on, a membership grants access the claim never named.""" + manager.restart(multi_tenant_auth(primary_idp)) + owner = primary_idp.token("owner", email=OWNER_EMAIL) + for name in (M_ALPHA, M_BETA, M_GAMMA): + assert api.status("POST", "/tenants", token=owner, body={"name": name}) in ( + 200, + 201, + ) + carol = primary_idp.token("carol", tenants=[M_ALPHA]) + assert api.status("GET", "/pipelines", token=carol, tenant=M_ALPHA) == 200 + + # An admin adds carol to m-beta; her claim still names only m-alpha. + added = api.v0( + "POST", + "/tenant/users", + token=owner, + tenant=M_BETA, + body={"subject": "carol", "role": "read"}, + ) + assert added.status_code == 200, added.text + assert api.status("GET", "/pipelines", token=carol, tenant=M_BETA) == 200 + + +def test_02_unknown_and_unjoined_tenants_answer_alike(api: Api, primary_idp: Issuer): + """The selector is no existence oracle: one answer for both.""" + carol = primary_idp.token("carol", tenants=[M_ALPHA]) + not_member = api.v0("GET", "/pipelines", token=carol, tenant=M_GAMMA) + unknown = api.v0("GET", "/pipelines", token=carol, tenant="m-absent") + assert not_member.status_code == unknown.status_code == 403 + assert not_member.json()["error_code"] == unknown.json()["error_code"] + + +def test_03_headerless_ambiguity_and_the_session_picker(api: Api, primary_idp: Issuer): + """Several memberships without a selector refuse; the session endpoint + answers with the list to pick from.""" + carol = primary_idp.token("carol", tenants=[M_ALPHA]) + assert api.status("GET", "/pipelines", token=carol) == 400 + session = api.v0("GET", "/config/session", token=carol) + assert session.status_code == 200, session.text + body = session.json() + assert body["tenant_id"] is None + assert {m["name"] for m in body["memberships"]} == {M_ALPHA, M_BETA} + + +def test_04_removal_re_enrolls_while_the_claim_still_names( + api: Api, primary_idp: Issuer +): + """Revocation is two-lever with provisioning on: deleting the membership + alone does not keep out a claim that still names the tenant.""" + owner = primary_idp.token("owner", email=OWNER_EMAIL) + members = api.v0("GET", "/tenant/users", token=owner, tenant=M_ALPHA).json() + carol_id = next(m["user_id"] for m in members if m["subject"] == "carol") + assert ( + api.status("DELETE", f"/tenant/users/{carol_id}", token=owner, tenant=M_ALPHA) + == 200 + ) + carol = primary_idp.token("carol", tenants=[M_ALPHA]) + assert api.status("GET", "/pipelines", token=carol, tenant=M_ALPHA) == 200 + + +def test_05_passive_claim_entries_enroll_but_never_create( + api: Api, primary_idp: Issuer +): + """A listed entry beyond the acting one joins an existing tenant at the + default role and cannot mint a tenant.""" + owner = primary_idp.token("owner", email=OWNER_EMAIL) + dave = primary_idp.token("dave", tenants=[M_ALPHA, M_GAMMA, "m-typo"]) + assert api.status("GET", "/pipelines", token=dave, tenant=M_ALPHA) == 200 + assert api.status("GET", "/tenants/m-typo", token=owner) == 404 + gamma_members = api.v0("GET", "/tenant/users", token=owner, tenant=M_GAMMA).json() + dave_row = next(m for m in gamma_members if m["subject"] == "dave") + assert dave_row["role"] == "read" + + +def test_06_empty_tenants_claim_is_no_claim(api: Api, primary_idp: Issuer): + """A claim mapping that evaluates empty derives the personal tenant rather + than a tenant literally named the empty string.""" + owner = primary_idp.token("owner", email=OWNER_EMAIL) + erin = primary_idp.token("erin", tenants=[""]) + assert api.status("GET", "/config/session", token=erin) == 200 + names = {t["name"] for t in api.v0("GET", "/tenants", token=owner).json()} + assert "" not in names + assert "erin" in names + + +def test_07_provisioning_off_denies_until_granted( + manager: Manager, api: Api, primary_idp: Issuer +): + """With provisioning off, a login creates nothing; access exists exactly + while a membership row does.""" + manager.restart(membership_auth(primary_idp)) + frank = primary_idp.token("frank") + assert api.status("GET", "/pipelines", token=frank) == 403 + session = api.v0("GET", "/config/session", token=frank) + assert session.status_code == 200, session.text + assert session.json()["tenant_id"] is None + assert session.json()["memberships"] == [] + + owner = primary_idp.token("owner", email=OWNER_EMAIL) + assert api.status("POST", "/tenants", token=owner, body={"name": "m-delta"}) in ( + 200, + 201, + ) + added = api.v0( + "POST", + "/tenant/users", + token=owner, + tenant="m-delta", + body={"subject": "frank", "role": "write"}, + ) + assert added.status_code == 200, added.text + + # A sole membership lands without a header. + session = api.v0("GET", "/config/session", token=frank).json() + assert session["tenant_name"] == "m-delta" + assert session["role"] == "write" + assert api.status("GET", "/pipelines", token=frank) == 200 + + +def test_08_removal_is_revocation_with_provisioning_off(api: Api, primary_idp: Issuer): + """Revocation in Feldera suffices once the issuer does not re-enroll.""" + owner = primary_idp.token("owner", email=OWNER_EMAIL) + members = api.v0("GET", "/tenant/users", token=owner, tenant="m-delta").json() + frank_id = next(m["user_id"] for m in members if m["subject"] == "frank") + assert ( + api.status("DELETE", f"/tenant/users/{frank_id}", token=owner, tenant="m-delta") + == 200 + ) + frank = primary_idp.token("frank") + assert api.status("GET", "/pipelines", token=frank) == 403 + + +def test_09_claims_are_ignored_with_provisioning_off(api: Api, primary_idp: Issuer): + """A token's claim neither creates a tenant nor grants access.""" + owner = primary_idp.token("owner", email=OWNER_EMAIL) + grace = primary_idp.token("grace", tenants=["m-echo", "m-delta"]) + assert api.status("GET", "/pipelines", token=grace, tenant="m-delta") == 403 + assert api.status("GET", "/tenants/m-echo", token=owner) == 404 diff --git a/python/tests/runtime/test_output_buffer_size_limit.py b/python/tests/runtime/test_output_buffer_size_limit.py index 55b80ba882e..afc06e88d65 100644 --- a/python/tests/runtime/test_output_buffer_size_limit.py +++ b/python/tests/runtime/test_output_buffer_size_limit.py @@ -20,7 +20,7 @@ import json from feldera import Pipeline, PipelineBuilder -from feldera.runtime_config import RuntimeConfig +from feldera.runtime_config import Resources, RuntimeConfig from feldera.testutils import FELDERA_TEST_NUM_WORKERS from tests import TEST_CLIENT from tests.utils import DeltaTestLocation, wait_for_condition @@ -90,7 +90,13 @@ def test_output_buffer_flushes_at_default_size_limit(pipeline_name): TEST_CLIENT, name=pipeline_name, sql=sql, - runtime_config=RuntimeConfig(workers=FELDERA_TEST_NUM_WORKERS), + runtime_config=RuntimeConfig( + workers=FELDERA_TEST_NUM_WORKERS, + # Buffering 10M+ records for the Delta sink peaks near 2 GiB; + # the default 1 GiB request under-reserves, and the kubelet + # evicts the pod when a packed node runs out of memory. + resources=Resources(memory_mb_min=4096), + ), ).create_or_replace() pipeline.start() diff --git a/python/tests/unit/test_httprequests_retry.py b/python/tests/unit/test_httprequests_retry.py index 0b7008bf42c..03cedddafbf 100644 --- a/python/tests/unit/test_httprequests_retry.py +++ b/python/tests/unit/test_httprequests_retry.py @@ -98,6 +98,7 @@ class TestRetryConfig: def test_defaults(self): cfg = RetryConfig() assert cfg.max_retries == 3 + assert cfg.deadline_seconds is None assert cfg.initial_backoff == 2.0 assert cfg.max_backoff == 64.0 assert cfg.multiplier == 2.0 @@ -118,6 +119,10 @@ def test_validation(self): RetryConfig(jitter=-0.1) with pytest.raises(ValueError): RetryConfig(unhealthy_backoff=-1.0) + with pytest.raises(ValueError): + RetryConfig(deadline_seconds=0.0) + with pytest.raises(ValueError): + RetryConfig(deadline_seconds=-1.0) def test_is_frozen(self): cfg = RetryConfig() @@ -254,6 +259,47 @@ def test_post_connection_error_is_not_retried(self): client.post("/foo") assert m.call_count == 1 + def test_post_marked_idempotent_retries_connection_error(self): + client = _make_client() + with patch_requests( + "post", + [requests.exceptions.ConnectionError("reset"), _make_response(200, b"{}")], + ) as m: + client.post("/foo", idempotent=True) + assert m.call_count == 2 + + def test_get_marked_not_idempotent_skips_connection_error_retry(self): + client = _make_client() + with patch_requests("get", [requests.exceptions.ConnectionError("down")]) as m: + with pytest.raises(FelderaCommunicationError): + client.send_request(requests.get, "/foo", idempotent=False) + assert m.call_count == 1 + + def test_idempotent_delete_treats_404_after_retry_as_success(self): + # First attempt drops the connection after the server applied the + # DELETE; the retry finds the resource gone. The postcondition holds. + client = _make_client() + with patch_requests( + "delete", + [ + requests.exceptions.ConnectionError("reset"), + _make_response(404, b'{"error":"not found"}'), + ], + ) as m: + assert client.delete("/foo", idempotent=True) is None + assert m.call_count == 2 + + def test_idempotent_delete_first_attempt_404_still_raises(self): + # No retry happened, so the resource genuinely did not exist. + client = _make_client() + with patch_requests( + "delete", [_make_response(404, b'{"error":"not found"}')] + ) as m: + with pytest.raises(FelderaAPIError) as exc_info: + client.delete("/foo", idempotent=True) + assert exc_info.value.status_code == 404 + assert m.call_count == 1 + def test_no_retries_when_max_retries_zero(self): client = _make_client(_fast_retry(max_retries=0)) with patch_requests("get", [_make_response(503)]) as m: @@ -269,6 +315,34 @@ def test_custom_retry_config_uses_its_limit(self): assert m.call_count == 6 # 1 initial + 5 retries +class TestRetryDeadline: + def test_deadline_lifts_attempt_cap(self): + # max_retries=1 alone would stop after 2 calls; the deadline keeps + # the client retrying until it succeeds. + client = _make_client(_fast_retry(max_retries=1, deadline_seconds=60.0)) + responses = [_make_response(503)] * 5 + [_make_response(200, b"{}")] + with patch_requests("get", responses) as m: + assert client.get("/foo") == {} + assert m.call_count == 6 + + def test_deadline_expiry_stops_retrying(self): + cfg = RetryConfig( + max_retries=0, + initial_backoff=0.05, + max_backoff=0.05, + multiplier=1.0, + deadline_seconds=0.12, + ) + client = _make_client(cfg) + with patch_requests("get", [_make_response(503)] * 100) as m: + with pytest.raises(FelderaAPIError) as exc_info: + client.get("/foo") + assert exc_info.value.status_code == 503 + # Attempts run at ~0, 0.05, 0.10, 0.15 seconds; the budget expires + # after at most four. Bounds are loose to tolerate scheduling delay. + assert 2 <= m.call_count <= 5 + + class TestRetryAfter: def test_retry_after_header_is_honored(self): client = _make_client(_fast_retry(max_backoff=0.0)) @@ -404,6 +478,35 @@ def test_unhealthy_502_exhausts_retries(self): assert m.call_count == 3 +class TestClientMarksIdempotentEndpoints: + @staticmethod + def _client_with_mock_http(): + from feldera.rest.feldera_client import FelderaClient + + # Skip the server-version handshake performed in __init__. + with mock.patch.object( + FelderaClient, "get_config", return_value=mock.Mock(version="x") + ): + client = FelderaClient(url="http://example.test") + client.http = mock.Mock() + return client + + def test_stop_pipeline_posts_idempotent(self): + client = self._client_with_mock_http() + client.stop_pipeline("p", force=True, wait=False) + assert client.http.post.call_args.kwargs["idempotent"] is True + + def test_pause_pipeline_posts_idempotent(self): + client = self._client_with_mock_http() + client.pause_pipeline("p", wait=False) + assert client.http.post.call_args.kwargs["idempotent"] is True + + def test_delete_pipeline_deletes_idempotent(self): + client = self._client_with_mock_http() + client.delete_pipeline("p") + assert client.http.delete.call_args.kwargs["idempotent"] is True + + class TestFelderaClientAcceptsRetryConfig: def test_passes_retry_config_through(self): from feldera.rest.feldera_client import FelderaClient diff --git a/python/uv.lock b/python/uv.lock index cd97b836c7d..2d6396dd155 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-08-01T12:36:10.470383847Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P1W" [[package]] @@ -366,6 +366,7 @@ dev = [ { name = "fastavro" }, { name = "pyiceberg", extra = ["sql-sqlite"] }, { name = "pytest" }, + { name = "pytest-rerunfailures" }, { name = "pytest-timeout" }, { name = "pytest-xdist" }, { name = "simplejson" }, @@ -393,6 +394,7 @@ dev = [ { name = "fastavro", specifier = ">=1.9.0" }, { name = "pyiceberg", extras = ["sql-sqlite"], specifier = ">=0.9.0" }, { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-rerunfailures", specifier = ">=15" }, { name = "pytest-timeout", specifier = ">=2.3.1" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "simplejson", specifier = "==3.20.1" }, @@ -1209,6 +1211,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "pytest-rerunfailures" +version = "16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/60/a90ca1cc6cffcb97b4260ed0ad2b7934b999d7c48abe4ea0840344862a3b/pytest_rerunfailures-16.4.tar.gz", hash = "sha256:8222d17c37eb7b9e4d6fc96a3c724ff4e1a5c97a5cc7cbb2c19e9282cfd21a11", size = 36635, upload-time = "2026-07-01T06:30:56.813Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/93/3cdcc4033444e822e01b573414b03fd37fd5533070c750477b8f5fa5224b/pytest_rerunfailures-16.4-py3-none-any.whl", hash = "sha256:f69b5beb39622c90d1e44bd945d826eff6db545dcf0b68f52b7e4ad15eaf6d6c", size = 16955, upload-time = "2026-07-01T06:30:55.333Z" }, +] + [[package]] name = "pytest-timeout" version = "2.4.0" diff --git a/scripts/dummy_oidc.py b/scripts/dummy_oidc.py index b1871f86108..80beef016b2 100755 --- a/scripts/dummy_oidc.py +++ b/scripts/dummy_oidc.py @@ -35,6 +35,7 @@ import json import secrets import ssl +import sys import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlencode, urlparse @@ -48,28 +49,45 @@ # Four demo identities offered on the login page. The effective role hint shows # what each becomes once provisioned via scripts/rbac_demo.py. +# +# `email_verified` is emitted verbatim, in both the token and the UserInfo +# answer, so the demo covers the three shapes a real provider sends: the +# boolean of the specification, the string AWS Cognito answers with, and a +# provider that will not vouch for the address at all. Feldera marks only a +# verified address as such, and an email entry in FELDERA_OWNERS matches only a +# verified one, which is why `owner` is verified here. ROLES: dict[str, dict] = { "reader": { "sub": "reader", "email": "reader@example.com", + "name": "Rita Reader", + "email_verified": True, "tenants": ["acme"], "hint": "read access in tenant acme", }, "writer": { "sub": "writer", "email": "writer@example.com", + "name": "Walt Writer", + # Unverified: the console shows the address without the check mark. + "email_verified": False, "tenants": ["acme"], "hint": "write access in tenant acme (after provisioning)", }, "admin": { "sub": "admin", "email": "admin@example.com", + "name": "Ada Admin", + # The string spelling AWS Cognito uses, which Feldera reads as verified. + "email_verified": "true", "tenants": ["acme"], "hint": "admin of tenant acme", }, "owner": { "sub": "owner", "email": "owner@example.com", + "name": "Otto Owner", + "email_verified": True, # No tenants claim: owner selects a tenant via the Feldera-Tenant header. "hint": "platform owner (FELDERA_OWNERS)", }, @@ -115,12 +133,20 @@ def __init__(self) -> None: def make_handler( - keys: KeyMaterial, issuer: str, default_audience: str, demo_tenants: list[str] + keys: KeyMaterial, + issuer: str, + default_audience: str, + demo_tenants: list[str], + omit_token_email: bool = False, ): """Build a request handler bound to one keypair and issuer. `demo_tenants` is the tenants claim given to the built-in tenanted demo users (reader/writer/admin) on the login page; the owner demo user stays untenanted. + + `omit_token_email` keeps `email` and `email_verified` out of access tokens, + as an AWS Cognito access token does, leaving the UserInfo endpoint as the + only place Feldera can learn them. """ # In-memory, single-process stores. Codes are single-use; refresh tokens map @@ -190,11 +216,19 @@ def one(name: str, default: str | None = None) -> str | None: # Optional claims: include only when the caller supplies them, so tenant # resolution can fall back to `sub` when absent (individual_tenant=true). - if (email := one("email")) is not None: + # An authentication time, as a real provider issues: Feldera reads it to + # decide when to ask the provider for the user's profile again. + if (auth_time := one("auth_time")) is not None: + claims["auth_time"] = int(auth_time) + else: + claims["auth_time"] = now + if (email := one("email")) is not None and not omit_token_email: claims["email"] = email # The manager only trusts an email for owner designation when the - # provider marks it verified, so this test IdP asserts it. - claims["email_verified"] = True + # provider marks it verified, so this test IdP asserts it unless the + # caller says otherwise. + verified = one("email_verified", "true") + claims["email_verified"] = verified == "true" if (tenant := one("tenant")) is not None: claims["tenant"] = tenant if (tenants := one("tenants")) is not None: @@ -218,10 +252,15 @@ def build_access_token(stored: dict) -> str: "iat": now, "exp": now + 3600, "token_use": "access", - "email": stored["email"], - "email_verified": True, + # When the user signed in, not when this token was minted. Refresh + # re-signs from the same record, so it survives, which is what makes + # it a usable marker of a new login. + "auth_time": stored["auth_time"], "scope": stored.get("scope", "openid profile email"), } + if not omit_token_email: + claims["email"] = stored["email"] + claims["email_verified"] = stored["email_verified"] if stored.get("tenants"): claims["tenants"] = stored["tenants"] return sign(claims) @@ -236,9 +275,10 @@ def build_id_token(stored: dict) -> str: "aud": stored.get("client_id") or "feldera", "iat": now, "exp": now + 3600, + "auth_time": stored["auth_time"], "email": stored["email"], - "email_verified": True, - "name": sub.title(), + "email_verified": stored["email_verified"], + "name": stored["name"], "preferred_username": sub, } if stored.get("nonce"): @@ -420,6 +460,11 @@ def one(name: str) -> str | None: auth_codes[code] = { "sub": profile["sub"], "email": profile["email"], + "email_verified": profile["email_verified"], + "name": profile["name"], + # This click is the authentication; every token minted from it, + # including refreshed ones, reports this instant. + "auth_time": int(time.time()), # Tenanted demo users get the configured demo tenants; the owner # demo user (no tenants in ROLES) stays untenanted. "tenants": demo_tenants if profile.get("tenants") else None, @@ -541,15 +586,24 @@ def _handle_userinfo(self) -> None: ) return sub = claims.get("sub", "") + # The demo identities answer from their profile rather than from the + # token, which is how a real provider behaves: an access token often + # carries no email at all, and the profile is what UserInfo is for. + # Anyone else (a machine mint, say) falls back to the token. + profile = next((p for p in ROLES.values() if p["sub"] == sub), None) info = { "sub": sub, - "email": claims.get("email"), - "name": sub.title(), + "email": profile["email"] if profile else claims.get("email"), + "email_verified": profile["email_verified"] + if profile + else claims.get("email_verified"), + "name": profile["name"] if profile else sub.title(), "preferred_username": sub, + "tenants": claims.get("tenants"), } - if claims.get("tenants"): - info["tenants"] = claims["tenants"] - self._send_json(info) + # A provider omits what it has nothing to say about rather than + # answering null, so drop the empty entries. + self._send_json({k: v for k, v in info.items() if v is not None}) # /logout def _handle_logout(self, query: dict[str, list[str]]) -> None: @@ -623,7 +677,8 @@ def print_startup(issuer: str, audience: str, port: int) -> None: print("-" * 70) print("Browser roles (pick one on the login page):") for role, profile in ROLES.items(): - print(f" {role:7} {profile['email']:20} -> {profile['hint']}") + verified = f"email_verified={profile['email_verified']!r}" + print(f" {role:7} {profile['email']:20} {verified:26} {profile['hint']}") print("-" * 70) print("Launch the pipeline-manager against it:") print( @@ -644,6 +699,12 @@ def print_startup(issuer: str, audience: str, port: int) -> None: def main() -> None: + # scripts/rbac_up.sh redirects this to a file, where Python would otherwise + # buffer whole blocks and leave the log empty for minutes at a time. The + # access log is the only record of what a browser asked the issuer for. + sys.stdout.reconfigure(line_buffering=True) + sys.stderr.reconfigure(line_buffering=True) + parser = argparse.ArgumentParser( description="Dummy OIDC/OAuth2 provider for local Feldera auth testing (DEV ONLY)." ) @@ -673,6 +734,14 @@ def main() -> None: "integration suite runs the provider this way.", ) parser.add_argument("--tls-key", default=None, help="Private key for --tls-cert") + parser.add_argument( + "--omit-token-email", + action="store_true", + help="Keep email and email_verified out of access tokens, as an AWS " + "Cognito access token does, so the UserInfo endpoint is the only place " + "Feldera can learn them. Note that an email entry in FELDERA_OWNERS then " + "matches nobody.", + ) args = parser.parse_args() if bool(args.tls_cert) != bool(args.tls_key): @@ -682,7 +751,9 @@ def main() -> None: issuer = args.issuer or f"{scheme}://localhost:{args.port}" demo_tenants = [t.strip() for t in args.demo_tenants.split(",") if t.strip()] keys = KeyMaterial() - handler = make_handler(keys, issuer, args.audience, demo_tenants) + handler = make_handler( + keys, issuer, args.audience, demo_tenants, args.omit_token_email + ) server = ThreadingHTTPServer(("0.0.0.0", args.port), handler) if args.tls_cert: context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) diff --git a/scripts/rbac_demo.py b/scripts/rbac_demo.py index 1ad813a9958..724d5c155e6 100755 --- a/scripts/rbac_demo.py +++ b/scripts/rbac_demo.py @@ -108,8 +108,10 @@ def main() -> int: # 4. The admin promotes writer -> write (admin manages its own tenant). code, resp = call(m, "GET", "/tenant/users", tok["admin"], tenant=tenant) - members = {u["email"]: u["user_id"] for u in resp.json()} if code == 200 else {} - wid = members.get("writer@example.com") + # Key by subject: it is the identity itself, whereas the email is display + # data the provider may not put in an access token at all. + members = {u["subject"]: u["user_id"] for u in resp.json()} if code == 200 else {} + wid = members.get("writer") if wid: code, _ = call( m,