feat: harden runtime lifecycle and application credentials
- enforce deterministic runtime lifecycle state transitions - add live graceful-to-forced shutdown escalation - align HTTP draining and worker shutdown with global deadline - guarantee deterministic shutdown hook ordering - add secure application client IDs and one-time client secrets - hash application secrets with BLAKE3 and constant-time verification - make credential creation and rotation transactionally auditable - enforce strict client_id authentication and redirect URI validation - add SQLite and PostgreSQL credential migrations - add application credential and runtime lifecycle acceptance tests - update Dioxus application management workflows - update security and architecture documentation
This commit is contained in:
1 parent
4c697e9adf
commit
dc5417334b
26 files changed
+2477
-183
No files matched your search
@@ -124,8 +124,12 @@ pub struct ApplicationView {
|
||||
#[serde(default)]
|
||||
pub client_id: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub credentials_configured: bool,
|
||||
#[serde(default)]
|
||||
pub redirect_urls: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub scopes: Vec<String>,
|
||||
@@ -141,6 +145,17 @@ pub struct ApplicationsResponse {
|
||||
pub applications: Vec<ApplicationView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct CreateApplicationResponse {
|
||||
pub application: ApplicationView,
|
||||
pub client_secret: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct RotateSecretResponse {
|
||||
pub client_secret: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct ServiceAccountView {
|
||||
pub id: String,
|
||||
|
||||
@@ -16,7 +16,7 @@ use dioxus::prelude::*;
|
||||
pub fn ApplicationsPage() -> Element {
|
||||
let state = use_context::<AppState>();
|
||||
let auth = state.auth;
|
||||
let can_manage = auth().has_permission("roles:manage");
|
||||
let can_manage = auth().has_permission("applications:manage");
|
||||
|
||||
let mut apps = use_signal(Vec::<ApplicationView>::new);
|
||||
let mut error = use_signal(|| Option::<String>::None);
|
||||
@@ -29,6 +29,12 @@ pub fn ApplicationsPage() -> Element {
|
||||
let mut show_create = use_signal(|| false);
|
||||
let mut name = use_signal(String::new);
|
||||
let mut slug = use_signal(String::new);
|
||||
let mut description = use_signal(String::new);
|
||||
let mut redirect_urls_raw = use_signal(String::new);
|
||||
let mut scopes_raw = use_signal(String::new);
|
||||
|
||||
let mut one_time_secret = use_signal(|| Option::<(ApplicationView, String)>::None);
|
||||
let mut rotate_target = use_signal(|| Option::<ApplicationView>::None);
|
||||
let mut delete_target = use_signal(|| Option::<ApplicationView>::None);
|
||||
|
||||
let reload = use_callback(move |_: ()| {
|
||||
@@ -50,7 +56,7 @@ pub fn ApplicationsPage() -> Element {
|
||||
|
||||
let mut filtered: Vec<_> = apps()
|
||||
.into_iter()
|
||||
.filter(|a| matches_query(&a.name, &query()) || matches_query(&a.slug, &query()))
|
||||
.filter(|a| matches_query(&a.name, &query()) || matches_query(&a.slug, &query()) || matches_query(&a.client_id, &query()))
|
||||
.collect();
|
||||
let sk = sort_key();
|
||||
filtered.sort_by(|a, b| match sk.as_str() {
|
||||
@@ -123,10 +129,16 @@ pub fn ApplicationsPage() -> Element {
|
||||
for a in page_items {
|
||||
{
|
||||
let app = a.clone();
|
||||
let app2 = a.clone();
|
||||
let app_rotate = a.clone();
|
||||
let app_delete = a.clone();
|
||||
rsx! {
|
||||
tr { key: "{a.id}",
|
||||
td { strong { "{a.name}" } }
|
||||
td {
|
||||
strong { "{a.name}" }
|
||||
if let Some(desc) = &a.description {
|
||||
div { class: "text-muted", style: "font-size: 0.8rem;", "{desc}" }
|
||||
}
|
||||
}
|
||||
td { code { "{a.client_id}" } }
|
||||
td { class: "text-muted",
|
||||
if a.redirect_urls.is_empty() { "—" } else { "{a.redirect_urls.join(\", \")}" }
|
||||
@@ -142,17 +154,26 @@ pub fn ApplicationsPage() -> Element {
|
||||
td { "{format_datetime(&a.created_at)}" }
|
||||
if can_manage {
|
||||
td { style: "text-align: right;",
|
||||
div { class: "actions",
|
||||
div { class: "actions", style: "display: inline-flex; gap: 0.25rem;",
|
||||
button {
|
||||
class: "btn btn-sm btn-outline",
|
||||
r#type: "button",
|
||||
onclick: move |_| rotate_target.set(Some(app_rotate.clone())),
|
||||
"Rotate Secret"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-sm btn-outline",
|
||||
r#type: "button",
|
||||
onclick: move |_| {
|
||||
let id = app.id.clone();
|
||||
let name = app.name.clone();
|
||||
let slug = app.slug.clone();
|
||||
let n = app.name.clone();
|
||||
let s = app.slug.clone();
|
||||
let desc = app.description.clone();
|
||||
let r_urls = if app.redirect_urls.is_empty() { None } else { Some(app.redirect_urls.clone()) };
|
||||
let sc = if app.scopes.is_empty() { None } else { Some(app.scopes.clone()) };
|
||||
let enabled = !app.enabled;
|
||||
spawn(async move {
|
||||
match api::update_application(&id, &name, &slug, enabled).await {
|
||||
match api::update_application(&id, &n, &s, desc.as_deref(), r_urls, sc, enabled).await {
|
||||
Ok(_) => {
|
||||
state.toast(ToastKind::Success, if enabled { "Enabled" } else { "Disabled" });
|
||||
reload.call(());
|
||||
@@ -166,7 +187,7 @@ pub fn ApplicationsPage() -> Element {
|
||||
button {
|
||||
class: "btn btn-sm btn-danger",
|
||||
r#type: "button",
|
||||
onclick: move |_| delete_target.set(Some(app2.clone())),
|
||||
onclick: move |_| delete_target.set(Some(app_delete.clone())),
|
||||
"Delete"
|
||||
}
|
||||
}
|
||||
@@ -189,16 +210,30 @@ pub fn ApplicationsPage() -> Element {
|
||||
oninput: move |v: String| {
|
||||
name.set(v.clone());
|
||||
if slug().is_empty() || slug() == slugify(&name()) {
|
||||
// keep in sync when empty-ish
|
||||
slug.set(slugify(&v));
|
||||
}
|
||||
slug.set(slugify(&v));
|
||||
},
|
||||
}
|
||||
TextInput {
|
||||
label: "Slug / Client ID",
|
||||
label: "Slug",
|
||||
value: slug(),
|
||||
oninput: move |v| slug.set(v),
|
||||
}
|
||||
TextInput {
|
||||
label: "Description (optional)",
|
||||
value: description(),
|
||||
oninput: move |v| description.set(v),
|
||||
}
|
||||
TextInput {
|
||||
label: "Redirect URLs (comma separated, optional)",
|
||||
value: redirect_urls_raw(),
|
||||
oninput: move |v| redirect_urls_raw.set(v),
|
||||
}
|
||||
TextInput {
|
||||
label: "Allowed Scopes (comma separated, optional)",
|
||||
value: scopes_raw(),
|
||||
oninput: move |v| scopes_raw.set(v),
|
||||
}
|
||||
div { class: "modal-footer", style: "margin-top:1rem; padding:0; border:none; background:transparent;",
|
||||
button { class: "btn btn-outline", r#type: "button",
|
||||
onclick: move |_| show_create.set(false), "Cancel" }
|
||||
@@ -207,13 +242,28 @@ pub fn ApplicationsPage() -> Element {
|
||||
onclick: move |_| {
|
||||
let n = name();
|
||||
let s = slug();
|
||||
let d = if description().trim().is_empty() { None } else { Some(description().trim().to_string()) };
|
||||
let r_urls = if redirect_urls_raw().trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(redirect_urls_raw().split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect::<Vec<_>>())
|
||||
};
|
||||
let sc = if scopes_raw().trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(scopes_raw().split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect::<Vec<_>>())
|
||||
};
|
||||
spawn(async move {
|
||||
match api::create_application(&n, &s).await {
|
||||
Ok(_) => {
|
||||
state.toast(ToastKind::Success, "Application created");
|
||||
match api::create_application(&n, &s, d.as_deref(), r_urls, sc).await {
|
||||
Ok(res) => {
|
||||
state.toast(ToastKind::Success, "Application registered successfully");
|
||||
show_create.set(false);
|
||||
name.set(String::new());
|
||||
slug.set(String::new());
|
||||
description.set(String::new());
|
||||
redirect_urls_raw.set(String::new());
|
||||
scopes_raw.set(String::new());
|
||||
one_time_secret.set(Some((res.application, res.client_secret)));
|
||||
reload.call(());
|
||||
}
|
||||
Err(e) => state.toast(ToastKind::Error, e.to_string()),
|
||||
@@ -225,6 +275,71 @@ pub fn ApplicationsPage() -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((app, sec)) = one_time_secret() {
|
||||
Modal {
|
||||
title: "Client Credentials Disclosed".to_string(),
|
||||
open: true,
|
||||
on_close: move |_| one_time_secret.set(None),
|
||||
div { class: "alert alert-warning", style: "margin-bottom: 1rem; padding: 0.75rem; border-radius: 4px; background: #fff3cd; color: #856404; border: 1px solid #ffeeba;",
|
||||
strong { "Important: " }
|
||||
"Store this client secret securely. It will never be displayed again after closing this dialog."
|
||||
}
|
||||
div { style: "display: flex; flex-direction: column; gap: 0.75rem;",
|
||||
div {
|
||||
label { style: "font-weight: 600; display: block; font-size: 0.85rem;", "Application Name" }
|
||||
div { "{app.name}" }
|
||||
}
|
||||
div {
|
||||
label { style: "font-weight: 600; display: block; font-size: 0.85rem;", "Client ID" }
|
||||
div { style: "display: flex; gap: 0.5rem; align-items: center;",
|
||||
code { style: "flex: 1; padding: 0.4rem; background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 4px;", "{app.client_id}" }
|
||||
}
|
||||
}
|
||||
div {
|
||||
label { style: "font-weight: 600; display: block; font-size: 0.85rem;", "Client Secret" }
|
||||
div { style: "display: flex; gap: 0.5rem; align-items: center;",
|
||||
code { style: "flex: 1; padding: 0.4rem; background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 4px; color: #d63384; word-break: break-all;", "{sec}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "modal-footer", style: "margin-top:1.5rem; padding:0; border:none; background:transparent; justify-content: flex-end;",
|
||||
button {
|
||||
class: "btn btn-primary", r#type: "button",
|
||||
onclick: move |_| one_time_secret.set(None),
|
||||
"I have saved my secret"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ConfirmDialog {
|
||||
title: "Rotate Client Secret".to_string(),
|
||||
message: format!(
|
||||
"Are you sure you want to rotate the client secret for \"{}\"? Any existing client using the current secret will be invalidated immediately.",
|
||||
rotate_target().as_ref().map(|a| a.name.as_str()).unwrap_or("")
|
||||
),
|
||||
open: rotate_target().is_some(),
|
||||
confirm_label: "Rotate Secret",
|
||||
danger: true,
|
||||
on_confirm: move |_| {
|
||||
if let Some(a) = rotate_target() {
|
||||
let target_app = a.clone();
|
||||
spawn(async move {
|
||||
match api::rotate_application_secret(&target_app.id).await {
|
||||
Ok(new_sec) => {
|
||||
state.toast(ToastKind::Success, "Client secret rotated");
|
||||
rotate_target.set(None);
|
||||
one_time_secret.set(Some((target_app, new_sec)));
|
||||
reload.call(());
|
||||
}
|
||||
Err(e) => state.toast(ToastKind::Error, e.to_string()),
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
on_cancel: move |_| rotate_target.set(None),
|
||||
}
|
||||
|
||||
ConfirmDialog {
|
||||
title: "Delete application".to_string(),
|
||||
message: format!(
|
||||
|
||||
+50
-8
@@ -59,9 +59,25 @@ fn client() -> Client {
|
||||
Client::new()
|
||||
}
|
||||
|
||||
pub trait RequestBuilderExtHelper {
|
||||
fn with_credentials_include(self) -> Self;
|
||||
}
|
||||
|
||||
impl RequestBuilderExtHelper for reqwest::RequestBuilder {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn with_credentials_include(self) -> Self {
|
||||
self.fetch_credentials_include()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn with_credentials_include(self) -> Self {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach credentials + optional bearer session token.
|
||||
fn authorize(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
let builder = builder.fetch_credentials_include();
|
||||
let builder = builder.with_credentials_include();
|
||||
if let Some(token) = session::load_access_token() {
|
||||
builder.header("Authorization", format!("Bearer {token}"))
|
||||
} else {
|
||||
@@ -180,7 +196,7 @@ pub async fn login(username: &str, password: &str) -> Result<LoginResponse, ApiE
|
||||
let url = api_url("/auth/login");
|
||||
let resp = client()
|
||||
.post(&url)
|
||||
.fetch_credentials_include()
|
||||
.with_credentials_include()
|
||||
.header("Accept", "application/json")
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
@@ -369,25 +385,51 @@ pub async fn list_applications() -> Result<Vec<ApplicationView>, ApiError> {
|
||||
Ok(r.applications)
|
||||
}
|
||||
|
||||
pub async fn create_application(name: &str, slug: &str) -> Result<ApplicationView, ApiError> {
|
||||
let body = serde_json::json!({ "name": name, "slug": slug });
|
||||
let r: Value = post_json("/applications", &body).await?;
|
||||
serde_json::from_value(r.get("application").cloned().unwrap_or(Value::Null))
|
||||
.map_err(|e| ApiError::Other(e.to_string()))
|
||||
pub async fn create_application(
|
||||
name: &str,
|
||||
slug: &str,
|
||||
description: Option<&str>,
|
||||
redirect_urls: Option<Vec<String>>,
|
||||
scopes: Option<Vec<String>>,
|
||||
) -> Result<CreateApplicationResponse, ApiError> {
|
||||
let body = serde_json::json!({
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"description": description,
|
||||
"redirect_urls": redirect_urls,
|
||||
"scopes": scopes,
|
||||
});
|
||||
post_json("/applications", &body).await
|
||||
}
|
||||
|
||||
pub async fn update_application(
|
||||
id: &str,
|
||||
name: &str,
|
||||
slug: &str,
|
||||
description: Option<&str>,
|
||||
redirect_urls: Option<Vec<String>>,
|
||||
scopes: Option<Vec<String>>,
|
||||
enabled: bool,
|
||||
) -> Result<ApplicationView, ApiError> {
|
||||
let body = serde_json::json!({ "name": name, "slug": slug, "enabled": enabled });
|
||||
let body = serde_json::json!({
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"description": description,
|
||||
"redirect_urls": redirect_urls,
|
||||
"scopes": scopes,
|
||||
"enabled": enabled,
|
||||
});
|
||||
let r: Value = patch_json(&format!("/applications/{id}"), &body).await?;
|
||||
serde_json::from_value(r.get("application").cloned().unwrap_or(Value::Null))
|
||||
.map_err(|e| ApiError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn rotate_application_secret(id: &str) -> Result<String, ApiError> {
|
||||
let r: RotateSecretResponse =
|
||||
post_json(&format!("/applications/{id}/secret"), &serde_json::json!({})).await?;
|
||||
Ok(r.client_secret)
|
||||
}
|
||||
|
||||
pub async fn delete_application(id: &str) -> Result<(), ApiError> {
|
||||
let _: Value = delete_json(&format!("/applications/{id}")).await?;
|
||||
Ok(())
|
||||
|
||||
Reference in new issue
Block a user