Skip to content

Commit f79d9ad

Browse files
committed
feat: improve startup config validation
1 parent d0cfa53 commit f79d9ad

File tree

12 files changed

+354
-87
lines changed

12 files changed

+354
-87
lines changed

crates/infera-management-api/src/handlers/auth.rs

Lines changed: 78 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,29 +40,97 @@ pub struct AppState {
4040
pub management_identity: Option<Arc<infera_management_types::ManagementIdentity>>,
4141
}
4242

43-
impl AppState {
43+
/// Builder for AppState to avoid too many function parameters
44+
pub struct AppStateBuilder {
45+
storage: Arc<Backend>,
46+
config: Arc<infera_management_core::ManagementConfig>,
47+
server_client: Arc<ServerApiClient>,
48+
worker_id: u16,
49+
leader: Option<Arc<infera_management_core::LeaderElection<Backend>>>,
50+
email_service: Option<Arc<infera_management_core::EmailService>>,
51+
webhook_client: Option<Arc<infera_management_core::WebhookClient>>,
52+
management_identity: Option<Arc<infera_management_types::ManagementIdentity>>,
53+
}
54+
55+
impl AppStateBuilder {
56+
/// Create a new AppStateBuilder with required parameters
4457
pub fn new(
4558
storage: Arc<Backend>,
4659
config: Arc<infera_management_core::ManagementConfig>,
4760
server_client: Arc<ServerApiClient>,
4861
worker_id: u16,
49-
leader: Option<Arc<infera_management_core::LeaderElection<Backend>>>,
50-
email_service: Option<Arc<infera_management_core::EmailService>>,
51-
webhook_client: Option<Arc<infera_management_core::WebhookClient>>,
52-
management_identity: Option<Arc<infera_management_types::ManagementIdentity>>,
5362
) -> Self {
5463
Self {
5564
storage,
5665
config,
5766
server_client,
5867
worker_id,
68+
leader: None,
69+
email_service: None,
70+
webhook_client: None,
71+
management_identity: None,
72+
}
73+
}
74+
75+
/// Set leader election component (optional)
76+
pub fn leader(mut self, leader: Arc<infera_management_core::LeaderElection<Backend>>) -> Self {
77+
self.leader = Some(leader);
78+
self
79+
}
80+
81+
/// Set email service (optional)
82+
pub fn email_service(mut self, email_service: Arc<infera_management_core::EmailService>) -> Self {
83+
self.email_service = Some(email_service);
84+
self
85+
}
86+
87+
/// Set webhook client (optional)
88+
pub fn webhook_client(mut self, webhook_client: Arc<infera_management_core::WebhookClient>) -> Self {
89+
self.webhook_client = Some(webhook_client);
90+
self
91+
}
92+
93+
/// Set management identity (optional)
94+
pub fn management_identity(mut self, management_identity: Arc<infera_management_types::ManagementIdentity>) -> Self {
95+
self.management_identity = Some(management_identity);
96+
self
97+
}
98+
99+
/// Build the AppState
100+
pub fn build(self) -> AppState {
101+
AppState {
102+
storage: self.storage,
103+
config: self.config,
104+
server_client: self.server_client,
105+
worker_id: self.worker_id,
59106
start_time: std::time::SystemTime::now(),
60-
leader,
61-
email_service,
62-
webhook_client,
63-
management_identity,
107+
leader: self.leader,
108+
email_service: self.email_service,
109+
webhook_client: self.webhook_client,
110+
management_identity: self.management_identity,
64111
}
65112
}
113+
}
114+
115+
impl AppState {
116+
/// Create AppState using the builder pattern
117+
///
118+
/// # Example
119+
///
120+
/// ```ignore
121+
/// let state = AppState::builder(storage, config, server_client, worker_id)
122+
/// .email_service(email_service)
123+
/// .webhook_client(webhook_client)
124+
/// .build();
125+
/// ```
126+
pub fn builder(
127+
storage: Arc<Backend>,
128+
config: Arc<infera_management_core::ManagementConfig>,
129+
server_client: Arc<ServerApiClient>,
130+
worker_id: u16,
131+
) -> AppStateBuilder {
132+
AppStateBuilder::new(storage, config, server_client, worker_id)
133+
}
66134

67135
/// Create AppState for testing with default configuration
68136
/// This is used by both unit tests and integration tests
@@ -134,7 +202,7 @@ server_api:
134202
start_time: std::time::SystemTime::now(),
135203
leader: None,
136204
email_service: Some(Arc::new(email_service)),
137-
webhook_client: None, // No webhook client in tests
205+
webhook_client: None, // No webhook client in tests
138206
management_identity: None, // No management identity in tests
139207
}
140208
}

crates/infera-management-api/src/handlers/jwks.rs

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -174,15 +174,12 @@ pub async fn get_management_jwks(
174174
State(state): State<AppState>,
175175
) -> Result<Json<JwksResponse>, (StatusCode, String)> {
176176
// Get the management identity from AppState
177-
let identity = state
178-
.management_identity
179-
.as_ref()
180-
.ok_or_else(|| {
181-
(
182-
StatusCode::SERVICE_UNAVAILABLE,
183-
"Management identity not configured".to_string(),
184-
)
185-
})?;
177+
let identity = state.management_identity.as_ref().ok_or_else(|| {
178+
(
179+
StatusCode::SERVICE_UNAVAILABLE,
180+
"Management identity not configured".to_string(),
181+
)
182+
})?;
186183

187184
let jwks = identity.to_jwks();
188185

crates/infera-management-api/src/handlers/organizations.rs

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use axum::{
22
extract::{Path, State},
3+
response::IntoResponse,
34
Extension, Json,
45
};
56
use infera_management_core::{error::Error as CoreError, IdGenerator, RepositoryContext};
@@ -21,7 +22,10 @@ use infera_management_types::{
2122
};
2223

2324
use crate::handlers::auth::{AppState, Result};
24-
use crate::middleware::{OrganizationContext, SessionContext};
25+
use crate::middleware::{
26+
dual_auth::extract_dual_auth_context, dual_auth::AuthContextType, OrganizationContext,
27+
SessionContext,
28+
};
2529

2630
/// Global limit on total organizations
2731
const GLOBAL_ORGANIZATION_LIMIT: i64 = 100_000;
@@ -255,6 +259,96 @@ pub async fn get_organization_by_id(
255259
}))
256260
}
257261

262+
/// Get organization with dual authentication
263+
///
264+
/// GET /v1/organizations/:org
265+
/// Auth: Session or Server JWT (dual authentication)
266+
///
267+
/// This endpoint handles both user requests (with organization membership checks)
268+
/// and server-to-server requests (without membership checks).
269+
///
270+
/// For session auth (users):
271+
/// - Checks organization membership
272+
/// - Returns GetOrganizationResponse with user's role
273+
/// - Returns 403 if user is not a member
274+
///
275+
/// For server JWT auth:
276+
/// - No membership check required
277+
/// - Returns OrganizationServerResponse with organization status
278+
pub async fn get_organization_dual_auth(
279+
State(state): State<AppState>,
280+
Path(org_id): Path<i64>,
281+
request: axum::extract::Request,
282+
) -> Result<axum::response::Response> {
283+
let auth_context = extract_dual_auth_context(&request)?;
284+
285+
match auth_context {
286+
AuthContextType::Session(session_ctx) => {
287+
// User request - verify membership and return full response
288+
let repos = RepositoryContext::new((*state.storage).clone());
289+
290+
// Check if user is a member of this organization
291+
let member = repos
292+
.org_member
293+
.get_by_org_and_user(org_id, session_ctx.user_id)
294+
.await?
295+
.ok_or_else(|| {
296+
CoreError::Authz("You are not a member of this organization".to_string())
297+
})?;
298+
299+
// Get organization
300+
let org = repos
301+
.org
302+
.get(org_id)
303+
.await?
304+
.ok_or_else(|| CoreError::NotFound("Organization not found".to_string()))?;
305+
306+
// Check if deleted
307+
if org.is_deleted() {
308+
return Err(CoreError::NotFound("Organization not found".to_string()).into());
309+
}
310+
311+
let response = GetOrganizationResponse {
312+
organization: OrganizationResponse {
313+
id: org.id,
314+
name: org.name,
315+
tier: tier_to_string(&org.tier),
316+
created_at: org.created_at.to_rfc3339(),
317+
role: role_to_string(&member.role),
318+
},
319+
};
320+
321+
Ok(Json(response).into_response())
322+
}
323+
AuthContextType::Server(_) => {
324+
// Server request - no membership check, return minimal response
325+
let repos = RepositoryContext::new((*state.storage).clone());
326+
327+
// Get organization
328+
let org = repos
329+
.org
330+
.get(org_id)
331+
.await?
332+
.ok_or_else(|| CoreError::NotFound("Organization not found".to_string()))?;
333+
334+
// Determine status
335+
let status = if org.is_deleted() {
336+
OrganizationStatus::Deleted
337+
} else {
338+
OrganizationStatus::Active
339+
};
340+
341+
let response = OrganizationServerResponse {
342+
id: org.id,
343+
name: org.name,
344+
status,
345+
};
346+
347+
Ok(Json(response).into_response())
348+
}
349+
}
350+
}
351+
258352
/// Update organization
259353
///
260354
/// PATCH /v1/organizations/:org
@@ -376,7 +470,9 @@ pub async fn delete_organization(
376470

377471
// Invalidate caches on all servers
378472
if let Some(ref webhook_client) = state.webhook_client {
379-
webhook_client.invalidate_organization(org_ctx.organization_id).await;
473+
webhook_client
474+
.invalidate_organization(org_ctx.organization_id)
475+
.await;
380476
}
381477

382478
Ok(Json(DeleteOrganizationResponse {

crates/infera-management-api/src/lib.rs

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ pub mod pagination;
1313
pub mod routes;
1414

1515
pub use handlers::AppState;
16-
pub use infera_management_types::identity::{ManagementIdentity, SharedManagementIdentity};
1716
pub use infera_management_types::dto::ErrorResponse;
17+
pub use infera_management_types::identity::{ManagementIdentity, SharedManagementIdentity};
1818
pub use middleware::{
1919
extract_session_context, get_user_vault_role, require_admin, require_admin_or_owner,
2020
require_manager, require_member, require_organization_member, require_owner, require_reader,
@@ -55,28 +55,39 @@ async fn shutdown_signal() {
5555
}
5656
}
5757

58+
/// Configuration for optional services in the Management API
59+
pub struct ServicesConfig {
60+
pub leader: Option<Arc<infera_management_core::LeaderElection<Backend>>>,
61+
pub email_service: Option<Arc<infera_management_core::EmailService>>,
62+
pub webhook_client: Option<Arc<infera_management_core::WebhookClient>>,
63+
pub management_identity: Option<Arc<ManagementIdentity>>,
64+
}
65+
5866
/// Start the Management API HTTP server
5967
pub async fn serve(
6068
storage: Arc<Backend>,
6169
config: Arc<ManagementConfig>,
6270
server_client: Arc<ServerApiClient>,
6371
worker_id: u16,
64-
leader: Option<Arc<infera_management_core::LeaderElection<Backend>>>,
65-
email_service: Option<Arc<infera_management_core::EmailService>>,
66-
webhook_client: Option<Arc<infera_management_core::WebhookClient>>,
67-
management_identity: Option<Arc<ManagementIdentity>>,
72+
services: ServicesConfig,
6873
) -> anyhow::Result<()> {
69-
// Create AppState with services
70-
let state = AppState::new(
71-
storage,
72-
config.clone(),
73-
server_client,
74-
worker_id,
75-
leader,
76-
email_service,
77-
webhook_client,
78-
management_identity,
79-
);
74+
// Create AppState with services using the builder pattern
75+
let mut builder = AppState::builder(storage, config.clone(), server_client, worker_id);
76+
77+
if let Some(leader) = services.leader {
78+
builder = builder.leader(leader);
79+
}
80+
if let Some(email_service) = services.email_service {
81+
builder = builder.email_service(email_service);
82+
}
83+
if let Some(webhook_client) = services.webhook_client {
84+
builder = builder.webhook_client(webhook_client);
85+
}
86+
if let Some(management_identity) = services.management_identity {
87+
builder = builder.management_identity(management_identity);
88+
}
89+
90+
let state = builder.build();
8091

8192
let app = create_router_with_state(state);
8293

crates/infera-management-api/src/middleware/server_auth.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,9 @@ pub async fn require_server_jwt(
130130
.map_err(|_| CoreError::Auth("Invalid authorization header".to_string()))?;
131131

132132
// Extract token from "Bearer <token>" format
133-
let token = auth_str
134-
.strip_prefix("Bearer ")
135-
.ok_or_else(|| CoreError::Auth("Authorization header must use Bearer scheme".to_string()))?;
133+
let token = auth_str.strip_prefix("Bearer ").ok_or_else(|| {
134+
CoreError::Auth("Authorization header must use Bearer scheme".to_string())
135+
})?;
136136

137137
// Decode header to get kid
138138
let header = decode_header(token)
@@ -207,9 +207,7 @@ pub async fn require_server_jwt(
207207
.to_string();
208208

209209
// Attach server context to request extensions
210-
request
211-
.extensions_mut()
212-
.insert(ServerContext { server_id });
210+
request.extensions_mut().insert(ServerContext { server_id });
213211

214212
Ok(next.run(request).await)
215213
}

crates/infera-management-api/src/routes.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,9 @@ pub fn create_router_with_state(state: AppState) -> axum::Router {
105105
.route("/v1/organizations/{org}/vaults", get(vaults::list_vaults))
106106
.route(
107107
"/v1/organizations/{org}/vaults/{vault}",
108-
patch(vaults::update_vault),
109-
)
110-
.route(
111-
"/v1/organizations/{org}/vaults/{vault}",
112-
delete(vaults::delete_vault),
108+
get(vaults::get_vault)
109+
.patch(vaults::update_vault)
110+
.delete(vaults::delete_vault),
113111
)
114112
// Vault user grant routes
115113
.route(
@@ -255,7 +253,7 @@ pub fn create_router_with_state(state: AppState) -> axum::Router {
255253
// Organization GET endpoint - used by users and by server for verification
256254
.route(
257255
"/v1/organizations/{org}",
258-
get(organizations::get_organization_by_id),
256+
get(organizations::get_organization_dual_auth),
259257
)
260258
// Vault GET endpoint - used by users and by server for vault ownership verification
261259
.route("/v1/vaults/{vault}", get(vaults::get_vault_by_id))

0 commit comments

Comments
 (0)