Skip to content

Commit 3778837

Browse files
feat: add automatic team assignment
Signed-off-by: Henry <mail@henrygressmann.de>
1 parent c8b1620 commit 3778837

23 files changed

Lines changed: 636 additions & 78 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ Since this is not a library, this changelog focuses on the changes that are rele
2828
- Added left and right arrow keyboard shortcuts for navigating dashboard date ranges
2929
- Added authenticated batch event ingestion endpoint
3030
- Added API key management UI
31-
- Added teams for assigning project read access to groups of users and internal projects visible to signed-in users
31+
- Added teams for assigning project read access to groups of users
32+
- Added internal project visibility for all signed-in users
3233
- Added a configurable maximum session duration
34+
- Added automatic team assignment for new SSO accounts
3335

3436
### ⚠️ Breaking Changes
3537

‎Cargo.lock‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎README.md‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ Add a small tracking script to your website with a single line of HTML. Works wi
4242

4343
**Single sign-on**\
4444
Manage accounts with Google Workspaces, Microsoft Entra, or your own OpenID Connect provider like Keycloak or Dex.
45-
Set `session_duration = "24h"` (or `LIWAN_SESSION_DURATION=24h`) to limit how long a dashboard login lasts. Sessions do not renew, and changing this setting does not shorten existing sessions. Admins can revoke sessions immediately in user settings.
4645

4746
## License
4847

‎crates/liwan/src/app/core.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ pub use api_keys::{ApiKeyAccess, LiwanApiKeys};
1414
pub use entities::LiwanEntities;
1515
pub use events::{LiwanEvents, PruneStats};
1616
pub use external_auth::{
17-
ExternalAuthLogin, ExternalAuthProvider, ExternalAuthSettings, ExternalAuthStart, LiwanExternalAuth,
17+
ExternalAuthLogin, ExternalAuthProvider, ExternalAuthSettings, ExternalAuthStart, GroupTeamMapping,
18+
LiwanExternalAuth,
1819
};
1920
pub use onboarding::LiwanOnboarding;
2021
pub use projects::LiwanProjects;

‎crates/liwan/src/app/core/external_auth/mod.rs‎

Lines changed: 260 additions & 11 deletions
Large diffs are not rendered by default.

‎crates/liwan/src/app/core/external_auth/provider.rs‎

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::net::IpAddr;
1+
use std::{collections::HashMap, net::IpAddr};
22

33
use anyhow::{Context, Result, bail};
44
use oauth2::{AuthType, EndpointMaybeSet, EndpointNotSet, EndpointSet};
@@ -22,10 +22,27 @@ struct ProviderClaims {
2222
hd: Option<String>,
2323
#[serde(default)]
2424
tid: Option<String>,
25+
#[serde(default)]
26+
groups: Option<serde_json::Value>,
27+
#[serde(default)]
28+
hasgroups: bool,
29+
#[serde(default, rename = "_claim_names")]
30+
claim_names: Option<serde_json::Value>,
31+
#[serde(flatten)]
32+
other: HashMap<String, serde_json::Value>,
2533
}
2634

2735
impl AdditionalClaims for ProviderClaims {}
2836

37+
impl ProviderClaims {
38+
fn group_values(&self, claim_name: &str) -> (Option<Vec<String>>, bool) {
39+
let values = if claim_name == "groups" { self.groups.as_ref() } else { self.other.get(claim_name) };
40+
let incomplete =
41+
self.hasgroups || self.claim_names.as_ref().is_some_and(|names| names.get(claim_name).is_some());
42+
(values.cloned().and_then(|value| serde_json::from_value(value).ok()), incomplete)
43+
}
44+
}
45+
2946
type ProviderIdTokenFields = IdTokenFields<
3047
ProviderClaims,
3148
EmptyExtraTokenFields,
@@ -76,6 +93,8 @@ pub(super) struct Provider {
7693
provider_key: String,
7794
policy: ProviderPolicy,
7895
allow_session_reuse: bool,
96+
additional_scopes: Vec<String>,
97+
group_claim_name: String,
7998
}
8099

81100
impl Provider {
@@ -110,7 +129,18 @@ impl Provider {
110129
.set_auth_type(auth_type)
111130
.set_redirect_uri(RedirectUrl::new(redirect_url.to_string())?);
112131

113-
Ok(Self { client: oidc_client, provider_key, policy, allow_session_reuse: settings.allow_session_reuse })
132+
Ok(Self {
133+
client: oidc_client,
134+
provider_key,
135+
policy,
136+
allow_session_reuse: settings.allow_session_reuse,
137+
additional_scopes: settings.additional_scopes.split_whitespace().map(str::to_string).collect(),
138+
group_claim_name: if settings.provider == ExternalAuthProvider::Microsoft {
139+
"groups".to_string()
140+
} else {
141+
settings.group_claim_name.clone()
142+
},
143+
})
114144
}
115145

116146
pub(super) fn authorize(&self) -> Authorization {
@@ -125,6 +155,8 @@ impl Provider {
125155
.add_scope(Scope::new("profile".to_string()))
126156
.add_scope(Scope::new("email".to_string()))
127157
.set_pkce_challenge(challenge);
158+
let request =
159+
self.additional_scopes.iter().fold(request, |request, scope| request.add_scope(Scope::new(scope.clone())));
128160
let request = if self.allow_session_reuse { request } else { request.add_prompt(CoreAuthPrompt::Login) };
129161
let (url, state, nonce) = request.url();
130162
Authorization { url, state: state.secret().clone(), secret: FlowSecret { nonce, verifier } }
@@ -167,10 +199,13 @@ impl Provider {
167199
.map(|value| value.as_str().to_string())
168200
.or_else(|| claims.email().map(|value| value.as_str().to_string()))
169201
.or_else(|| claims.name().and_then(|name| name.get(None)).map(|value| value.as_str().to_string()));
202+
let (groups, groups_overage) = claims.additional_claims().group_values(&self.group_claim_name);
170203
Ok(ExternalIdentity {
171204
provider_key: self.provider_key.clone(),
172205
subject: claims.subject().as_str().to_string(),
173206
username_hint,
207+
groups,
208+
groups_overage,
174209
})
175210
}
176211
}
@@ -298,4 +333,25 @@ mod tests {
298333
"https://login.microsoftonline.com/72f988bf-86f1-41af-91ab-2d7cd011db47/v2.0"
299334
);
300335
}
336+
337+
#[test]
338+
fn reads_configured_group_claims_and_detects_incomplete_lists() {
339+
let claims: ProviderClaims = serde_json::from_value(serde_json::json!({
340+
"groups": ["engineering"],
341+
"roles": ["analyst"],
342+
}))
343+
.unwrap();
344+
assert_eq!(claims.group_values("groups"), (Some(vec!["engineering".into()]), false));
345+
assert_eq!(claims.group_values("roles"), (Some(vec!["analyst".into()]), false));
346+
assert_eq!(claims.group_values("missing"), (None, false));
347+
348+
let malformed: ProviderClaims = serde_json::from_value(serde_json::json!({ "roles": [7] })).unwrap();
349+
assert_eq!(malformed.group_values("roles"), (None, false));
350+
351+
let incomplete: ProviderClaims = serde_json::from_value(serde_json::json!({
352+
"groups": [7], "hasgroups": true, "_claim_names": { "groups": "src1" }
353+
}))
354+
.unwrap();
355+
assert_eq!(incomplete.group_values("groups"), (None, true));
356+
}
301357
}

‎crates/liwan/src/app/mod.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ mod db;
33

44
pub mod models;
55
pub use core::reports;
6-
pub use core::{ExternalAuthLogin, ExternalAuthProvider, ExternalAuthSettings, ExternalAuthStart, LiwanExternalAuth};
6+
pub use core::{
7+
ExternalAuthLogin, ExternalAuthProvider, ExternalAuthSettings, ExternalAuthStart, GroupTeamMapping,
8+
LiwanExternalAuth,
9+
};
710
use std::sync::Arc;
811

912
use crate::{config::Config, utils::writable::check_directory_writable};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
alter table external_auth_settings add column default_team_id text;
2+
alter table external_auth_settings add column group_team_mappings text not null default '[]';
3+
alter table external_auth_settings add column additional_scopes text not null default '';
4+
alter table external_auth_settings add column group_claim_name text not null default '';

‎crates/liwan/src/web/routes/external_auth.rs‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize};
1919
use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder};
2020

2121
use crate::{
22-
app::{ExternalAuthProvider, ExternalAuthSettings},
22+
app::{ExternalAuthProvider, ExternalAuthSettings, GroupTeamMapping},
2323
config::Config,
2424
web::{
2525
RouterState,
@@ -98,6 +98,10 @@ struct ExternalAuthSettingsResponse {
9898
tenant_id: Option<String>,
9999
allow_user_creation: bool,
100100
allow_session_reuse: bool,
101+
default_team_id: Option<String>,
102+
group_team_mappings: Vec<GroupTeamMapping>,
103+
additional_scopes: String,
104+
group_claim_name: String,
101105
callback_url: String,
102106
}
103107

@@ -117,6 +121,10 @@ struct UpdateExternalAuthSettings {
117121
tenant_id: Option<String>,
118122
allow_user_creation: bool,
119123
allow_session_reuse: bool,
124+
default_team_id: Option<String>,
125+
group_team_mappings: Vec<GroupTeamMapping>,
126+
additional_scopes: String,
127+
group_claim_name: String,
120128
}
121129

122130
#[derive(Deserialize, JsonSchema)]
@@ -180,6 +188,12 @@ async fn update_settings(
180188
}
181189
ExternalAuthProvider::Google => true,
182190
};
191+
if !same_client_registration
192+
&& !request.group_team_mappings.is_empty()
193+
&& request.group_team_mappings == existing.group_team_mappings
194+
{
195+
http_bail!(StatusCode::BAD_REQUEST, "reconfigure group mappings when changing the provider registration");
196+
}
183197
let client_secret = if request.clear_client_secret {
184198
None
185199
} else if let Some(client_secret) = request.client_secret {
@@ -200,6 +214,10 @@ async fn update_settings(
200214
tenant_id: request.tenant_id,
201215
allow_user_creation: request.allow_user_creation,
202216
allow_session_reuse: request.allow_session_reuse,
217+
default_team_id: request.default_team_id,
218+
group_team_mappings: request.group_team_mappings,
219+
additional_scopes: request.additional_scopes,
220+
group_claim_name: request.group_claim_name,
203221
};
204222
if let Err(error) = app.external_auth.update_settings(&settings).await {
205223
tracing::debug!(%error, "external authentication settings validation failed");
@@ -299,6 +317,10 @@ fn settings_response(app: &RouterState, settings: ExternalAuthSettings) -> Exter
299317
tenant_id: settings.tenant_id,
300318
allow_user_creation: settings.allow_user_creation,
301319
allow_session_reuse: settings.allow_session_reuse,
320+
default_team_id: settings.default_team_id,
321+
group_team_mappings: settings.group_team_mappings,
322+
additional_scopes: settings.additional_scopes,
323+
group_claim_name: settings.group_claim_name,
302324
callback_url: app.external_auth.callback_url().to_string(),
303325
}
304326
}

‎crates/liwan/tests/external_auth.rs‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ fn settings(overrides: Value) -> Value {
2020
"allowedDomain": null,
2121
"tenantId": null,
2222
"allowUserCreation": false,
23-
"allowSessionReuse": true
23+
"allowSessionReuse": true,
24+
"defaultTeamId": null,
25+
"groupTeamMappings": [],
26+
"additionalScopes": "",
27+
"groupClaimName": ""
2428
});
2529
settings.as_object_mut().unwrap().extend(overrides.as_object().unwrap().clone());
2630
settings
@@ -134,6 +138,53 @@ async fn invalid_enabled_settings_are_not_persisted() -> Result<()> {
134138
Ok(())
135139
}
136140

141+
#[tokio::test]
142+
async fn team_assignment_settings_are_validated() -> Result<()> {
143+
let (app, client, cookies) = authenticated_client(UserRole::Admin).await?;
144+
let team_id = app.teams.create("Engineering")?;
145+
let headers = || vec![("cookie".to_string(), cookies.clone())];
146+
let response = client
147+
.put_with_headers(
148+
"/api/dashboard/admin/auth",
149+
settings(json!({
150+
"defaultTeamId": team_id,
151+
"groupTeamMappings": [{ "groupId": "engineering", "teamId": team_id }],
152+
"groupClaimName": "groups",
153+
"additionalScopes": "groups"
154+
})),
155+
headers(),
156+
)
157+
.await;
158+
response.assert_status_success();
159+
let body: Value = response.json();
160+
assert_eq!(body["defaultTeamId"], team_id);
161+
assert_eq!(body["groupTeamMappings"][0]["groupId"], "engineering");
162+
assert_eq!(body["additionalScopes"], "groups");
163+
164+
let response = client
165+
.put_with_headers(
166+
"/api/dashboard/admin/auth",
167+
settings(json!({
168+
"defaultTeamId": team_id,
169+
"groupClaimName": "groups",
170+
"groupTeamMappings": [
171+
{ "groupId": "engineering", "teamId": team_id },
172+
{ "groupId": "engineering", "teamId": team_id }
173+
]
174+
})),
175+
headers(),
176+
)
177+
.await;
178+
response.assert_status_bad_request();
179+
180+
let response = client
181+
.put_with_headers("/api/dashboard/admin/auth", settings(json!({ "defaultTeamId": "missing" })), headers())
182+
.await;
183+
response.assert_status_bad_request();
184+
assert_eq!(app.external_auth.settings()?.default_team_id.as_deref(), Some(team_id.as_str()));
185+
Ok(())
186+
}
187+
137188
#[tokio::test]
138189
async fn oidc_start_uses_discovery_pkce_and_state_cookie() -> Result<()> {
139190
let (issuer, server) = mock_oidc_provider().await?;

0 commit comments

Comments
 (0)