diff --git a/src/common/meta/service_account.rs b/src/common/meta/service_account.rs index b3dba781a2..542551eb84 100644 --- a/src/common/meta/service_account.rs +++ b/src/common/meta/service_account.rs @@ -31,6 +31,14 @@ pub struct APIToken { pub user: String, } +#[derive(Serialize, Deserialize, ToSchema)] +pub struct ServiceAccountCreateResponse { + pub code: u16, + pub message: String, + pub token: String, + pub user: String, +} + #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Eq, PartialEq, Default)] pub struct UpdateServiceAccountRequest { #[serde(default)] @@ -92,6 +100,34 @@ mod tests { assert_eq!(request.last_name, "Name"); } + #[test] + fn test_service_account_create_response() { + let resp = ServiceAccountCreateResponse { + code: 200, + message: "User saved successfully".to_string(), + token: "abcd1234567890ef".to_string(), + user: "sa@example.com".to_string(), + }; + assert_eq!(resp.code, 200); + assert_eq!(resp.token, "abcd1234567890ef"); + assert_eq!(resp.user, "sa@example.com"); + } + + #[test] + fn test_service_account_create_response_serialization() { + let resp = ServiceAccountCreateResponse { + code: 200, + message: "User saved successfully".to_string(), + token: "tok123".to_string(), + user: "sa@example.com".to_string(), + }; + let serialized = serde_json::to_string(&resp).unwrap(); + let deserialized: ServiceAccountCreateResponse = serde_json::from_str(&serialized).unwrap(); + assert_eq!(resp.token, deserialized.token); + assert_eq!(resp.user, deserialized.user); + assert_eq!(resp.code, deserialized.code); + } + #[test] fn test_update_service_account_request_default() { let request = UpdateServiceAccountRequest::default(); diff --git a/src/common/meta/user.rs b/src/common/meta/user.rs index 9c3115dbc0..d2b4d45ab7 100644 --- a/src/common/meta/user.rs +++ b/src/common/meta/user.rs @@ -192,6 +192,8 @@ pub struct UserResponse { #[serde(default, skip_serializing_if = "Option::is_none")] pub orgs: Option>, pub created_at: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token: Option, } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] @@ -629,6 +631,7 @@ mod tests { role: UserRole::Admin, }]), created_at: 1234567890, + token: None, }; assert_eq!(response.email, "test@example.com"); @@ -651,6 +654,7 @@ mod tests { is_external: false, orgs: None, created_at: 1234567890, + token: None, }], }; diff --git a/src/handler/http/request/service_accounts/mod.rs b/src/handler/http/request/service_accounts/mod.rs index 3e52d548b3..81ba240660 100644 --- a/src/handler/http/request/service_accounts/mod.rs +++ b/src/handler/http/request/service_accounts/mod.rs @@ -394,61 +394,3 @@ pub async fn delete_bulk( err, }) } - -/// GetAPIToken - -#[utoipa::path( - get, - path = "/{org_id}/service_accounts/{email_id}", - context_path = "/api", - tag = "ServiceAccounts", - operation_id = "GetServiceAccountToken", - summary = "Get service account API token", - description = "Retrieves the current API token for a specific service account. The API token is used for authenticating automated systems and applications when making API requests. \ - \ - **Security Note:** Service accounts with `allow_static_token=false` will return a masked token (***MASKED***) instead of the actual token. These accounts must use the `assume_service_account` API to obtain temporary session tokens. \ - \ - Keep tokens secure and rotate them regularly for security best practices. If the token is compromised, use the update endpoint with rotateToken=true to generate a new one.", - security( - ("Authorization"= []) - ), - params( - ("org_id" = String, Path, description = "Organization name"), - ("email_id" = String, Path, description = "Service Account email id"), - ), - responses( - (status = 200, description = "Success", content_type = "application/json", body = Object), - (status = 404, description = "NotFound", content_type = "application/json", body = ()), - ), - extensions( - ("x-o2-ratelimit" = json!({"module": "Service Accounts", "operation": "get"})), - ("x-o2-mcp" = json!({"description": "Get service account token", "category": "users"})) - ) -)] -pub async fn get_api_token( - Path((org, user_id)): Path<(String, String)>, - Headers(_user_email): Headers, -) -> Response { - let config = config::get_config(); - if !config.auth.service_account_enabled { - return MetaHttpResponse::forbidden("Service Accounts Not Enabled"); - } - - // Always return single token for the requested org - let org_id = Some(org.as_str()); - match crate::service::organization::get_passcode(org_id, &user_id).await { - Ok(passcode) => ( - StatusCode::OK, - Json(APIToken { - token: passcode.passcode, - user: passcode.user, - }), - ) - .into_response(), - Err(e) => ( - StatusCode::NOT_FOUND, - Json(MetaHttpResponse::error(StatusCode::NOT_FOUND, e)), - ) - .into_response(), - } -} diff --git a/src/handler/http/router/mod.rs b/src/handler/http/router/mod.rs index 6483b4eb72..33804a16a2 100644 --- a/src/handler/http/router/mod.rs +++ b/src/handler/http/router/mod.rs @@ -744,7 +744,7 @@ pub fn service_routes() -> Router { // Service accounts .route("/{org_id}/service_accounts", get(service_accounts::list).post(service_accounts::save)) .route("/{org_id}/service_accounts/bulk", delete(service_accounts::delete_bulk)) - .route("/{org_id}/service_accounts/{email_id}", get(service_accounts::get_api_token).put(service_accounts::update).delete(service_accounts::delete)) + .route("/{org_id}/service_accounts/{email_id}", put(service_accounts::update).delete(service_accounts::delete)) // MCP .route("/{org_id}/mcp", get(mcp::handle_mcp_get).post(mcp::handle_mcp_post)) diff --git a/src/handler/http/router/openapi.rs b/src/handler/http/router/openapi.rs index fa3693f00a..5d8a6fd02b 100644 --- a/src/handler/http/router/openapi.rs +++ b/src/handler/http/router/openapi.rs @@ -167,7 +167,6 @@ use crate::{common::meta, handler::http::request}; request::service_accounts::save, request::service_accounts::update, request::service_accounts::delete, - request::service_accounts::get_api_token, request::mcp::handle_mcp_post, request::mcp::handle_mcp_get, request::mcp::oauth_authorization_server_metadata, diff --git a/src/service/users.rs b/src/service/users.rs index 964e397c9b..ac8a3c1d67 100644 --- a/src/service/users.rs +++ b/src/service/users.rs @@ -46,6 +46,7 @@ use crate::{ meta::{ http::HttpResponse as MetaHttpResponse, organization::{DEFAULT_ORG, OrgRoleMapping}, + service_account::ServiceAccountCreateResponse, user::{ UpdateUser, UserList, UserOrgRole, UserRequest, UserResponse, UserUpdateMode, get_default_user_org, @@ -56,6 +57,14 @@ use crate::{ service::{db, organization}, }; +fn redact_token(token: &str) -> String { + if token.len() <= 4 { + "*".repeat(token.len()) + } else { + format!("{}{}", &token[..4], "*".repeat(token.len() - 4)) + } +} + pub async fn post_user( org_id: &str, mut usr_req: UserRequest, @@ -140,6 +149,7 @@ pub async fn post_user( let password = get_hash(&usr_req.password, &salt); let password_ext = get_hash(&usr_req.password, &cfg.auth.ext_auth_salt); let token = generate_random_string(16); + let token_for_response = token.clone(); let rum_token = format!("rum{}", generate_random_string(16)); let org_id = org_id.replace(' ', "_"); let user = usr_req.to_new_dbuser( @@ -191,7 +201,16 @@ pub async fn post_user( } } } - Ok(MetaHttpResponse::ok("User saved successfully")) + if usr_req.role.base_role == UserRole::ServiceAccount { + Ok(MetaHttpResponse::json(ServiceAccountCreateResponse { + code: 200, + message: "User saved successfully".to_string(), + token: token_for_response, + user: usr_req.email.clone(), + })) + } else { + Ok(MetaHttpResponse::ok("User saved successfully")) + } } else { Ok(MetaHttpResponse::bad_request("User already exists")) } @@ -751,6 +770,7 @@ pub async fn list_users( is_external: user.is_external, orgs: None, created_at: 0, // Not used + token: None, }); } return Ok(MetaHttpResponse::json(UserList { data: user_list })); @@ -780,6 +800,11 @@ pub async fn list_users( user.role.ne(&UserRole::ServiceAccount) }; if should_include { + let token = if user.role.eq(&UserRole::ServiceAccount) { + Some(redact_token(&org_user.value().token)) + } else { + None + }; user_list.push(UserResponse { email: user.email.clone(), role: user.role.to_string(), @@ -788,6 +813,7 @@ pub async fn list_users( is_external: user.is_external, orgs: None, created_at: org_user.value().created_at, + token, }); } } @@ -814,6 +840,7 @@ pub async fn list_users( is_external: user.user_type.is_external(), orgs: user_orgs.get(user.email.as_str()).cloned(), created_at, + token: None, }); } } @@ -842,6 +869,7 @@ pub async fn list_users( is_external: root_user.is_external, orgs: None, created_at: 0, + token: None, }); } } @@ -1220,6 +1248,28 @@ mod tests { // Mutex to ensure test setup is serialized to prevent race conditions static TEST_SETUP_LOCK: tokio::sync::OnceCell> = tokio::sync::OnceCell::const_new(); + #[test] + fn test_redact_token_normal() { + assert_eq!(redact_token("abcd1234567890ef"), "abcd************"); + } + + #[test] + fn test_redact_token_exactly_four_chars() { + // tokens of 4 chars or fewer are fully masked (nothing revealed) + assert_eq!(redact_token("abcd"), "****"); + } + + #[test] + fn test_redact_token_shorter_than_four() { + assert_eq!(redact_token("ab"), "**"); + assert_eq!(redact_token(""), ""); + } + + #[test] + fn test_redact_token_five_chars() { + assert_eq!(redact_token("abcde"), "abcd*"); + } + #[test] fn test_is_user_from_org() { let target_org = "org1".to_string(); diff --git a/tests/api-testing/tests/pages/serviceaccount_page.py b/tests/api-testing/tests/pages/serviceaccount_page.py index fe4c0e72a6..0cf416f062 100644 --- a/tests/api-testing/tests/pages/serviceaccount_page.py +++ b/tests/api-testing/tests/pages/serviceaccount_page.py @@ -33,17 +33,11 @@ class ServiceAccountPage: ) assert response.status_code == 200, f"Failed to create service account: {response.content.decode()}" - print(f"Service account created successfully: {response.json()}") + response_data = response.json() + print(f"Service account created successfully: {response_data}") - response_get = session.get( - f"{base_url}api/{org_id}/service_accounts/{email_address}", - headers=headers - ) - assert response_get.status_code == 200, f"Failed to get service account: {response_get.content.decode()}" - - print(f"Service account created successfully: {response_get.json()}") - - token = response_get.json()['token'] + # Token is returned in the create response + token = response_data['token'] print(f"Token: {token}") diff --git a/tests/api-testing/tests/test_serviceaccounts.py b/tests/api-testing/tests/test_serviceaccounts.py index 6a19f7daad..973f223331 100644 --- a/tests/api-testing/tests/test_serviceaccounts.py +++ b/tests/api-testing/tests/test_serviceaccounts.py @@ -93,14 +93,11 @@ def test_e2e_creategetdeleteserviceaccounts(create_session, base_url): assert ( resp_create_serviceaccount.status_code == 200 ), f"Expected 200, but got {resp_create_serviceaccount.status_code} {resp_create_serviceaccount.content}" - resp_get_serviceaccount = session.get( - - f"{base_url}api/{org_id}/service_accounts/{email_id}" - ) - assert ( - resp_get_serviceaccount.status_code == 200 - ), f"Getting this service account, but got {resp_get_serviceaccount.status_code} {resp_get_serviceaccount.content}" + # Token is returned in the create response; verify it is present + create_data = resp_create_serviceaccount.json() + assert "token" in create_data and create_data["token"], \ + f"Expected token in create response, but got: {create_data}" resp_delete_serviceaccount = session.delete( f"{base_url}api/{org_id}/service_accounts/{email_id}" @@ -134,14 +131,6 @@ def test_e2e_creategetupdatedeleteserviceaccounts(create_session, base_url): assert ( resp_create_serviceaccount.status_code == 200 ), f"Expected 200, but got {resp_create_serviceaccount.status_code} {resp_create_serviceaccount.content}" - resp_get_serviceaccount = session.get( - - f"{base_url}api/{org_id}/service_accounts/{email_id}" - ) - - assert ( - resp_get_serviceaccount.status_code == 200 - ), f"Getting this service account, but got {resp_get_serviceaccount.status_code} {resp_get_serviceaccount.content}" payload = { "email":email_id, @@ -190,14 +179,6 @@ def test_e2e_creategetrefreshdeleteserviceaccounts(create_session, base_url): assert ( resp_create_serviceaccount.status_code == 200 ), f"Expected 200, but got {resp_create_serviceaccount.status_code} {resp_create_serviceaccount.content}" - resp_get_serviceaccount = session.get( - - f"{base_url}api/{org_id}/service_accounts/{email_id}" - ) - - assert ( - resp_get_serviceaccount.status_code == 200 - ), f"Getting this service account, but got {resp_get_serviceaccount.status_code} {resp_get_serviceaccount.content}" resp_refresh_serviceaccount = session.put( f"{base_url}api/{org_id}/service_accounts/{email_id}?rotateToken=true", json=payload diff --git a/web/src/components/iam/serviceAccounts/ServiceAccountsList.spec.js b/web/src/components/iam/serviceAccounts/ServiceAccountsList.spec.js index eb3bb6e0ca..ac75faf6d4 100644 --- a/web/src/components/iam/serviceAccounts/ServiceAccountsList.spec.js +++ b/web/src/components/iam/serviceAccounts/ServiceAccountsList.spec.js @@ -24,7 +24,6 @@ vi.mock("@/services/service_accounts", () => ({ default: { list: vi.fn(), delete: vi.fn(), - get_service_token: vi.fn(), refresh_token: vi.fn() } })); @@ -88,7 +87,6 @@ describe("ServiceAccountsList Component", () => { // Reset mock implementations vi.mocked(service_accounts.list).mockReset(); vi.mocked(service_accounts.delete).mockReset(); - vi.mocked(service_accounts.get_service_token).mockReset(); vi.mocked(service_accounts.refresh_token).mockReset(); // Setup default successful response for list @@ -258,103 +256,6 @@ describe("ServiceAccountsList Component", () => { }); describe("Token Management", () => { - it("gets service token successfully", async () => { - const mockToken = "test-token-123"; - vi.mocked(service_accounts.get_service_token).mockResolvedValue({ - data: { token: mockToken } - }); - - const row = { - email: "service1@example.com", - isLoading: false, - isTokenVisible: false - }; - - await wrapper.vm.getServiceToken(row); - - expect(service_accounts.get_service_token).toHaveBeenCalledWith("test-org", "service1@example.com"); - expect(row.token).toBe(mockToken); - expect(row.isTokenVisible).toBe(true); - }); - - it("toggles token visibility when token exists", () => { - const row = { - token: "existing-token", - isTokenVisible: false - }; - - wrapper.vm.getServiceToken(row); - - expect(row.isTokenVisible).toBe(true); - }); - - it("hides token when already visible", () => { - const row = { - token: "existing-token", - isTokenVisible: true - }; - - wrapper.vm.getServiceToken(row); - - expect(row.isTokenVisible).toBe(false); - }); - - it("gets service token for service token variable", async () => { - const mockToken = "service-token-456"; - vi.mocked(service_accounts.get_service_token).mockResolvedValue({ - data: { token: mockToken } - }); - - const row = { email: "service@example.com" }; - - await wrapper.vm.getServiceToken(row, false); - - expect(wrapper.vm.serviceToken).toBe(mockToken); - }); - - it("handles token fetch error", async () => { - const mockError = { - response: { - status: 500, - data: { message: "Token fetch failed" } - } - }; - - vi.mocked(service_accounts.get_service_token).mockRejectedValue(mockError); - - const row = { - email: "service1@example.com", - isLoading: true, - isTokenVisible: false - }; - - await wrapper.vm.getServiceToken(row, true); - await flushPromises(); - - expect(row.isLoading).toBe(false); - }); - - it("does not show error notification for 403 token fetch", async () => { - const mockError = { - response: { - status: 403, - data: { message: "Forbidden" } - } - }; - - vi.mocked(service_accounts.get_service_token).mockRejectedValue(mockError); - - const row = { - email: "service1@example.com", - isLoading: false, - isTokenVisible: false - }; - - await wrapper.vm.getServiceToken(row, true); - - expect(notifyMock).not.toHaveBeenCalled(); - }); - it("refreshes service token successfully", async () => { const mockToken = "refreshed-token-789"; vi.mocked(service_accounts.refresh_token).mockResolvedValue({ @@ -366,10 +267,10 @@ describe("ServiceAccountsList Component", () => { isLoading: false }; - await wrapper.vm.refreshServiceToken(row, true); + await wrapper.vm.refreshServiceToken(row); expect(service_accounts.refresh_token).toHaveBeenCalledWith("test-org", "service1@example.com"); - expect(row.token).toBe(mockToken); + expect(wrapper.vm.serviceToken).toBe(mockToken); }); it("confirms refresh action", () => { @@ -552,45 +453,22 @@ describe("ServiceAccountsList Component", () => { }); describe("Token Display", () => { - it("masks token correctly", () => { - const token = "abcd1234efgh5678"; - const maskedToken = wrapper.vm.maskToken(token); - expect(maskedToken).toBe("abcd **** 5678"); + it("redactToken masks all but first 4 chars", () => { + expect(wrapper.vm.redactToken("abcd1234567890ef")).toBe("abcd************"); }); - it("handles short tokens in maskToken", () => { - const shortToken = "abc123"; - const maskedToken = wrapper.vm.maskToken(shortToken); - expect(maskedToken).toBe("abc123"); + it("redactToken with exactly 4 chars masks all", () => { + // tokens of 4 chars or fewer are fully masked (nothing revealed) + expect(wrapper.vm.redactToken("abcd")).toBe("****"); }); - it("displays token correctly when visible", () => { - const row = { - token: "test-token-123", - isTokenVisible: true - }; - - const displayToken = wrapper.vm.getDisplayToken(row); - expect(displayToken).toBe("test **** -123"); + it("redactToken with fewer than 4 chars masks all", () => { + expect(wrapper.vm.redactToken("ab")).toBe("**"); + expect(wrapper.vm.redactToken("")).toBe(""); }); - it("displays stars when token not visible", () => { - const row = { - token: "test-token-123", - isTokenVisible: false - }; - - const displayToken = wrapper.vm.getDisplayToken(row); - expect(displayToken).toBe("* * * * * * * * * * * * * * * *"); - }); - - it("displays stars when no token", () => { - const row = { - isTokenVisible: true - }; - - const displayToken = wrapper.vm.getDisplayToken(row); - expect(displayToken).toBe("* * * * * * * * * * * * * * * *"); + it("redactToken with 5 chars masks last char only", () => { + expect(wrapper.vm.redactToken("abcde")).toBe("abcd*"); }); }); diff --git a/web/src/components/iam/serviceAccounts/ServiceAccountsList.vue b/web/src/components/iam/serviceAccounts/ServiceAccountsList.vue index 16c652ce09..1b8303fa91 100644 --- a/web/src/components/iam/serviceAccounts/ServiceAccountsList.vue +++ b/web/src/components/iam/serviceAccounts/ServiceAccountsList.vue @@ -78,48 +78,8 @@ along with this program. If not, see . @@ -268,7 +228,7 @@ along with this program. If not, see . no-caps class="no-border" color="primary" - @click="refreshServiceToken(toBeRefreshed,false)" + @click="refreshServiceToken(toBeRefreshed)" > {{ t("user.ok") }} @@ -522,39 +482,6 @@ export default defineComponent({ confirmDelete.value = true; deleteUserEmail = props.row.email; }; - const getServiceToken = async (row:any, fromColum = true) =>{ - if(fromColum){ row.isLoading = true; - if(!row.isTokenVisible){ - row.isTokenVisible = true; - setTimeout(() => { - row.isTokenVisible = false; - }, 5 * 60 * 1000); - } - else{ - row.isTokenVisible = false; - } - if(row.token){ - row.isLoading = false; - return; - } - } - service_accounts.get_service_token(store.state.selectedOrganization.identifier,row.email).then((res)=>{ - if(fromColum) row.token = res.data.token; - else serviceToken.value = res.data.token; - }).catch((err)=>{ - if(err.response?.status != 403){ - $q.notify({ - color: "negative", - message: `Error fetching token: ${err.response?.data?.message || 'Unknown error'}`, - }); - } - - }).finally(()=>{ - row.isLoading = false; - }); - } - - const getServiceAccountsUsers = async () =>{ const dismiss = $q.notify({ spinner: true, @@ -576,7 +503,7 @@ export default defineComponent({ email: maskText(data.email), first_name: data.first_name, last_name: data.last_name, - isTokenVisible: false, + token: data.token || '', }; }); @@ -645,6 +572,11 @@ export default defineComponent({ }); }; + const redactToken = (token: string): string => { + if (token.length <= 4) return '*'.repeat(token.length); + return token.slice(0, 4) + '*'.repeat(token.length - 4); + }; + const addMember = async (res: any, data: any, operationType: string) => { showAddUserDialog.value = false; if (res.code == 200 ) { @@ -654,7 +586,7 @@ export default defineComponent({ message: "Service Account created successfully.", }); - await getServiceToken(data, false); + serviceToken.value = res.token; isShowToken.value = true; if ( store.state.selectedOrganization.identifier == data.organization @@ -667,6 +599,7 @@ export default defineComponent({ email: data.email, first_name: data.first_name, last_name: data.last_name, + token: res.token ? redactToken(res.token) : '', }; serviceAccountsState.service_accounts_users.push(user); @@ -768,18 +701,18 @@ export default defineComponent({ } }; - const refreshServiceToken = async (row:any,fromColum = true) =>{ - if(fromColum) row.isLoading = true; + const refreshServiceToken = async (row:any) =>{ + row.isLoading = true; await service_accounts.refresh_token(store.state.selectedOrganization.identifier,row.email).then((res)=>{ - row.token = res.data.token; serviceToken.value = res.data.token; - row.isTokenVisible = true; isShowToken.value = true; $q.notify({ color: "positive", message: "Service token refreshed successfully.", }); + + getServiceAccountsUsers(); }).catch((err)=>{ if(err.response?.status != 403){ $q.notify({ @@ -787,7 +720,7 @@ export default defineComponent({ message: err.response?.data?.message || "Error while refreshing token.", }); } - + }).finally(()=>{ row.isLoading = false; }); @@ -823,17 +756,6 @@ export default defineComponent({ toBeRefreshed.value = row; }; - const getDisplayToken = (row: any): string => { - if (!row.token) return '* * * * * * * * * * * * * * * *'; - if (!row.isTokenVisible) return '* * * * * * * * * * * * * * * *'; - return maskToken(row.token); - }; - - const maskToken = (token: string): string => { - if (token.length <= 8) return token; - return `${token.slice(0, 4)} **** ${token.slice(-4)}`; - }; - const filterData = (rows: any, terms: any) => { var filtered = []; terms = terms.toLowerCase(); @@ -880,7 +802,6 @@ export default defineComponent({ addUser, confirmDeleteAction, outlinedVisibility, - getServiceToken, deleteUser, getServiceAccountsUsers, pagination, @@ -898,8 +819,6 @@ export default defineComponent({ isShowToken, serviceToken, confirmRefreshAction, - getDisplayToken, - maskToken, filterQuery, filterData, userEmail, @@ -918,6 +837,7 @@ export default defineComponent({ confirmBulkDelete, openBulkDeleteDialog, bulkDeleteServiceAccounts, + redactToken, }; }, }); @@ -925,9 +845,6 @@ export default defineComponent({