Skip to content

Commit 08ab9ff

Browse files
jubradclaude
andcommitted
frontegg-mock: add tenant management API endpoints
Adds mock implementations for Frontegg tenant API: - GET /tenants - list all tenants - GET /tenants/:id - get tenant by ID - POST /tenants - create tenant - PATCH /tenants/:id/metadata - update tenant metadata - POST /identity/resources/auth/v1/api-token - vendor API token auth These endpoints are being added to assist with local development of internal services in the cloud repo. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5c20ba1 commit 08ab9ff

File tree

7 files changed

+368
-0
lines changed

7 files changed

+368
-0
lines changed

Cargo.lock

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

src/frontegg-mock/src/handlers.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ pub mod auth;
1111
pub mod group;
1212
pub mod scim;
1313
pub mod sso;
14+
pub mod tenant;
1415
pub mod user;
1516

1617
pub use auth::*;
1718
pub use group::*;
1819
pub use scim::*;
1920
pub use sso::*;
21+
pub use tenant::*;
2022
pub use user::*;

src/frontegg-mock/src/handlers/auth.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use crate::server::Context;
1212
use crate::utils::{RefreshTokenTarget, generate_access_token, generate_refresh_token};
1313
use axum::{Json, extract::State, http::StatusCode};
1414
use mz_frontegg_auth::{ApiTokenResponse, ClaimTokenType};
15+
use serde::{Deserialize, Serialize};
1516
use std::sync::Arc;
1617
use std::sync::atomic::Ordering;
1718

@@ -137,3 +138,47 @@ pub async fn handle_post_token_refresh(
137138
}
138139
}
139140
}
141+
142+
/// Request body for vendor authentication.
143+
#[derive(Debug, Clone, Deserialize)]
144+
#[serde(rename_all = "camelCase")]
145+
pub struct VendorAuthRequest {
146+
pub client_id: String,
147+
pub secret: String,
148+
}
149+
150+
/// Response body for vendor authentication.
151+
#[derive(Debug, Clone, Serialize)]
152+
#[serde(rename_all = "camelCase")]
153+
pub struct VendorAuthResponse {
154+
pub token: String,
155+
pub expires_in: i64,
156+
}
157+
158+
/// Handle POST /auth/vendor
159+
/// Authenticates a vendor SDK client and returns a JWT token.
160+
pub async fn handle_post_auth_vendor(
161+
State(context): State<Arc<Context>>,
162+
Json(_request): Json<VendorAuthRequest>,
163+
) -> Result<Json<VendorAuthResponse>, StatusCode> {
164+
// For the mock, we accept any client_id/secret and return a valid token.
165+
// In production, this would validate the credentials.
166+
let now_secs = (context.now)() / 1000;
167+
let exp_secs = now_secs as i64 + context.expires_in_secs;
168+
let token = jsonwebtoken::encode(
169+
&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256),
170+
&serde_json::json!({
171+
"sub": "vendor",
172+
"iss": context.issuer,
173+
"iat": now_secs,
174+
"exp": exp_secs,
175+
}),
176+
&context.encoding_key,
177+
)
178+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
179+
180+
Ok(Json(VendorAuthResponse {
181+
token,
182+
expires_in: context.expires_in_secs,
183+
}))
184+
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
// Copyright Materialize, Inc. and contributors. All rights reserved.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the LICENSE file.
5+
//
6+
// As of the Change Date specified in that file, in accordance with
7+
// the Business Source License, use of this software will be governed
8+
// by the Apache License, Version 2.0.
9+
10+
use std::collections::BTreeSet;
11+
use std::sync::Arc;
12+
13+
use axum::Json;
14+
use axum::extract::{Path, State};
15+
use chrono::Utc;
16+
use axum::http::StatusCode;
17+
use serde::Deserialize;
18+
use uuid::Uuid;
19+
20+
use crate::models::{TenantConfig, TenantResponse};
21+
use crate::server::Context;
22+
23+
/// Handle GET /tenants/resources/tenants/v1
24+
/// Lists all tenants.
25+
///
26+
/// If no explicit tenants are configured, derives tenants from the users' tenant_ids.
27+
pub async fn handle_list_tenants(
28+
State(context): State<Arc<Context>>,
29+
) -> Result<Json<Vec<TenantResponse>>, StatusCode> {
30+
let tenants = context.tenants.lock().unwrap();
31+
32+
// If explicit tenants are configured, return those
33+
if !tenants.is_empty() {
34+
let responses: Vec<TenantResponse> = tenants.values().map(TenantResponse::from).collect();
35+
return Ok(Json(responses));
36+
}
37+
drop(tenants);
38+
39+
// Otherwise, derive tenants from users
40+
let users = context.users.lock().unwrap();
41+
let tenant_ids: BTreeSet<_> = users.values().map(|u| u.tenant_id).collect();
42+
43+
let now = Utc::now();
44+
let responses: Vec<TenantResponse> = tenant_ids
45+
.into_iter()
46+
.map(|id| TenantResponse {
47+
id,
48+
name: format!("Tenant {}", &id.to_string()[..8]),
49+
metadata: serde_json::json!({}),
50+
creator_name: None,
51+
creator_email: None,
52+
created_at: now,
53+
updated_at: now,
54+
deleted_at: None,
55+
})
56+
.collect();
57+
58+
Ok(Json(responses))
59+
}
60+
61+
/// Handle GET /tenants/resources/tenants/v1/:id
62+
/// Gets a single tenant by ID.
63+
///
64+
/// Note: The frontegg client expects a Vec<Tenant> response (and pops the first element).
65+
pub async fn handle_get_tenant(
66+
State(context): State<Arc<Context>>,
67+
Path(id): Path<Uuid>,
68+
) -> Result<Json<Vec<TenantResponse>>, StatusCode> {
69+
let tenants = context.tenants.lock().unwrap();
70+
71+
// If the tenant exists in the explicit tenants map, return it
72+
if let Some(tenant) = tenants.get(&id) {
73+
return Ok(Json(vec![TenantResponse::from(tenant)]));
74+
}
75+
drop(tenants);
76+
77+
// Check if the tenant exists in users
78+
let users = context.users.lock().unwrap();
79+
let tenant_exists = users.values().any(|u| u.tenant_id == id);
80+
drop(users);
81+
82+
if !tenant_exists {
83+
return Ok(Json(vec![])); // Empty vec will cause client to return NOT_FOUND
84+
}
85+
86+
// Return a derived tenant
87+
let now = Utc::now();
88+
let response = TenantResponse {
89+
id,
90+
name: format!("Tenant {}", &id.to_string()[..8]),
91+
metadata: serde_json::json!({}),
92+
creator_name: None,
93+
creator_email: None,
94+
created_at: now,
95+
updated_at: now,
96+
deleted_at: None,
97+
};
98+
99+
Ok(Json(vec![response]))
100+
}
101+
102+
/// Request body for creating a tenant.
103+
#[derive(Debug, Deserialize)]
104+
#[serde(rename_all = "camelCase")]
105+
pub struct CreateTenantRequest {
106+
#[serde(default = "Uuid::new_v4")]
107+
pub tenant_id: Uuid,
108+
pub name: String,
109+
#[serde(default)]
110+
pub metadata: serde_json::Value,
111+
pub creator_name: Option<String>,
112+
pub creator_email: Option<String>,
113+
}
114+
115+
/// Handle POST /tenants/resources/tenants/v1
116+
/// Creates a new tenant.
117+
pub async fn handle_create_tenant(
118+
State(context): State<Arc<Context>>,
119+
Json(body): Json<CreateTenantRequest>,
120+
) -> Result<Json<TenantResponse>, StatusCode> {
121+
let now = Utc::now();
122+
let tenant = TenantConfig {
123+
id: body.tenant_id,
124+
name: body.name,
125+
metadata: body.metadata,
126+
creator_name: body.creator_name,
127+
creator_email: body.creator_email,
128+
created_at: now,
129+
updated_at: now,
130+
deleted_at: None,
131+
};
132+
133+
let response = TenantResponse::from(&tenant);
134+
let mut tenants = context.tenants.lock().unwrap();
135+
tenants.insert(tenant.id, tenant);
136+
137+
Ok(Json(response))
138+
}
139+
140+
/// Request body for setting tenant metadata.
141+
#[derive(Debug, Deserialize)]
142+
pub struct SetTenantMetadataRequest {
143+
pub metadata: serde_json::Value,
144+
}
145+
146+
/// Handle POST /tenants/resources/tenants/v1/:id/metadata
147+
/// Sets/updates tenant metadata.
148+
pub async fn handle_set_tenant_metadata(
149+
State(context): State<Arc<Context>>,
150+
Path(id): Path<Uuid>,
151+
Json(body): Json<SetTenantMetadataRequest>,
152+
) -> Result<Json<TenantResponse>, StatusCode> {
153+
let mut tenants = context.tenants.lock().unwrap();
154+
155+
// If the tenant exists in the explicit tenants map, update it
156+
if let Some(tenant) = tenants.get_mut(&id) {
157+
// Merge the new metadata with existing metadata
158+
if let Some(existing) = tenant.metadata.as_object_mut() {
159+
if let Some(new_obj) = body.metadata.as_object() {
160+
for (k, v) in new_obj {
161+
existing.insert(k.clone(), v.clone());
162+
}
163+
}
164+
} else {
165+
tenant.metadata = body.metadata;
166+
}
167+
tenant.updated_at = Utc::now();
168+
return Ok(Json(TenantResponse::from(&*tenant)));
169+
}
170+
drop(tenants);
171+
172+
// Check if the tenant exists in users
173+
let users = context.users.lock().unwrap();
174+
let tenant_exists = users.values().any(|u| u.tenant_id == id);
175+
drop(users);
176+
177+
if !tenant_exists {
178+
return Err(StatusCode::NOT_FOUND);
179+
}
180+
181+
// Create a new tenant entry with the metadata
182+
let now = Utc::now();
183+
let tenant = TenantConfig {
184+
id,
185+
name: format!("Tenant {}", &id.to_string()[..8]),
186+
metadata: body.metadata,
187+
creator_name: None,
188+
creator_email: None,
189+
created_at: now,
190+
updated_at: now,
191+
deleted_at: None,
192+
};
193+
let response = TenantResponse::from(&tenant);
194+
let mut tenants = context.tenants.lock().unwrap();
195+
tenants.insert(id, tenant);
196+
197+
Ok(Json(response))
198+
}

src/frontegg-mock/src/models.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@
1010
pub mod group;
1111
pub mod scim;
1212
pub mod sso;
13+
pub mod tenant;
1314
pub mod token;
1415
pub mod user;
1516
pub mod utils;
1617

1718
pub use group::*;
1819
pub use scim::*;
1920
pub use sso::*;
21+
pub use tenant::*;
2022
pub use token::*;
2123
pub use user::*;
2224
pub use utils::*;

0 commit comments

Comments
 (0)