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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
461 changes: 447 additions & 14 deletions Cargo.lock

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions crates/tracevault-core/src/extensions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub struct ExtensionRegistry {
pub pricing: Arc<dyn PricingProvider>,
pub compliance: Arc<dyn ComplianceProvider>,
pub permissions: Arc<dyn PermissionsProvider>,
pub sso: Arc<dyn SsoProvider>,
}

impl Clone for ExtensionRegistry {
Expand All @@ -44,6 +45,7 @@ impl Clone for ExtensionRegistry {
pricing: Arc::clone(&self.pricing),
compliance: Arc::clone(&self.compliance),
permissions: Arc::clone(&self.permissions),
sso: Arc::clone(&self.sso),
}
}
}
Expand Down Expand Up @@ -106,3 +108,37 @@ pub trait PermissionsProvider: Send + Sync {
fn has_permission(&self, role: &str, perm: Permission) -> bool;
fn is_valid_role(&self, role: &str) -> bool;
}

// -- SSO --

#[derive(Debug, Clone)]
pub struct SsoUserInfo {
pub subject: String,
pub email: String,
pub name: Option<String>,
}

#[async_trait]
pub trait SsoProvider: Send + Sync {
fn is_enabled(&self) -> bool;

/// Build the OIDC authorization URL that the user's browser should be redirected to.
async fn authorization_url(
&self,
issuer_url: &str,
client_id: &str,
client_secret: &str,
redirect_uri: &str,
state: &str,
) -> Result<String, String>;

/// Exchange an authorization code for user identity claims.
async fn exchange_code(
&self,
issuer_url: &str,
client_id: &str,
client_secret: &str,
redirect_uri: &str,
code: &str,
) -> Result<SsoUserInfo, String>;
}
1 change: 1 addition & 0 deletions crates/tracevault-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ reqwest = { version = "0.12", features = ["json"] }
async-trait = "0.1"
aes-gcm = "0.10"
dotenvy = "0.15.7"
urlencoding = "2"
thiserror.workspace = true
tower_governor = "0.6"
pgvector = { version = "0.4", features = ["sqlx"] }
Expand Down
40 changes: 40 additions & 0 deletions crates/tracevault-server/migrations/018_sso.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
-- SSO configuration per organization
CREATE TABLE org_sso_configs (
org_id UUID PRIMARY KEY REFERENCES orgs(id) ON DELETE CASCADE,
issuer_url TEXT NOT NULL,
client_id TEXT NOT NULL,
client_secret_encrypted TEXT NOT NULL,
client_secret_nonce TEXT NOT NULL,
allowed_domains TEXT[] NOT NULL,
enforce BOOLEAN NOT NULL DEFAULT true,
auto_provision BOOLEAN NOT NULL DEFAULT true,
default_role TEXT NOT NULL DEFAULT 'developer',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Link users to external IdP identities
CREATE TABLE user_sso_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
org_id UUID NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
issuer TEXT NOT NULL,
subject TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(org_id, subject),
UNIQUE(user_id, org_id)
);

-- CSRF state for OIDC authorization flow
CREATE TABLE sso_auth_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES orgs(id),
state TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_sso_auth_requests_state ON sso_auth_requests(state);

-- Allow SSO-provisioned users to have no password
ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL;
70 changes: 63 additions & 7 deletions crates/tracevault-server/src/api/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,19 +218,75 @@ pub async fn login(
State(state): State<AppState>,
Json(req): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, AppError> {
let row =
sqlx::query_as::<_, (Uuid, String)>("SELECT id, password_hash FROM users WHERE email = $1")
.bind(&req.email)
.fetch_optional(&state.pool)
.await?
.ok_or(AppError::Unauthorized)?;
let row = sqlx::query_as::<_, (Uuid, Option<String>)>(
"SELECT id, password_hash FROM users WHERE email = $1",
)
.bind(&req.email)
.fetch_optional(&state.pool)
.await?
.ok_or(AppError::Unauthorized)?;

let (user_id, password_hash_opt) = row;

let (user_id, password_hash) = row;
let password_hash = password_hash_opt.ok_or(AppError::Unauthorized)?;

if !verify_password(&req.password, &password_hash) {
return Err(AppError::Unauthorized);
}

// Check SSO enforcement
let sso_enforced_orgs = sqlx::query_as::<_, (uuid::Uuid, String)>(
"SELECT m.org_id, m.role FROM user_org_memberships m
JOIN org_sso_configs s ON m.org_id = s.org_id
WHERE m.user_id = $1 AND s.enforce = true",
)
.bind(user_id)
.fetch_all(&state.pool)
.await?;

if !sso_enforced_orgs.is_empty() {
// Check if user has any non-SSO org, or is admin/owner in an SSO org
let non_sso_orgs: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM user_org_memberships m
WHERE m.user_id = $1
AND NOT EXISTS (
SELECT 1 FROM org_sso_configs s WHERE s.org_id = m.org_id AND s.enforce = true
)",
)
.bind(user_id)
.fetch_one(&state.pool)
.await?;

let is_breakglass = sso_enforced_orgs
.iter()
.any(|(_, role)| role == "owner" || role == "admin");

if non_sso_orgs == 0 && !is_breakglass {
return Err(AppError::Forbidden(
"Your organization requires SSO login. Use the SSO button on the login page."
.into(),
));
}

// Log break-glass if applicable
if is_breakglass && non_sso_orgs == 0 {
for (org_id, _) in &sso_enforced_orgs {
crate::audit::log(
&state.pool,
crate::audit::user_action(
*org_id,
user_id,
"auth.login.breakglass",
"user",
Some(user_id),
Some(serde_json::json!({"email": &req.email})),
),
)
.await;
}
}
}

let (raw_token, token_hash) = generate_session_token();
let expires_at = chrono::Utc::now() + chrono::Duration::days(30);

Expand Down
1 change: 1 addition & 0 deletions crates/tracevault-server/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ pub mod policies;
pub mod pricing;
pub mod repos;
pub mod session_detail;
pub mod sso;
pub mod stream;
pub mod traces_ui;
Loading
Loading