mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
fix: do not show service account token post creation (#11110)
So far, service account token could be viewed and copied even after an SA was created. This was bad design and posed risk of impersonation. The changes prevent this from happening by returning only the redacted token in the API response. Full token with create API itself, and removing GET API to get the service account details (which returned raw token). The same affects token rotation as well.
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -192,6 +192,8 @@ pub struct UserResponse {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub orgs: Option<Vec<OrgRoleMapping>>,
|
||||
pub created_at: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}],
|
||||
};
|
||||
|
||||
|
||||
@@ -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<UserEmail>,
|
||||
) -> 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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
+51
-1
@@ -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<Mutex<()>> = 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();
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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*");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -78,48 +78,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<template #body-cell-token="props">
|
||||
<q-td :props="props" side >
|
||||
<div class="tw:flex tw:items-center" v-if="props.row.isLoading">
|
||||
<q-spinner-dots color="primary" />
|
||||
</div>
|
||||
<!-- Display the token or masked text based on visibility -->
|
||||
<div v-else class="tw:flex tw:items-center">
|
||||
<span
|
||||
style="
|
||||
display: inline-block;
|
||||
width: 150px; /* Set a fixed width */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
{{ getDisplayToken(props.row) }}
|
||||
</span>
|
||||
|
||||
<!-- Button to fetch or toggle token visibility -->
|
||||
<q-btn
|
||||
:icon="props.row.isTokenVisible ? 'visibility_off' : 'visibility'"
|
||||
:title="props.row.isTokenVisible ? t('serviceAccounts.hideToken') : t('serviceAccounts.showToken')"
|
||||
unelevated
|
||||
size="sm"
|
||||
round
|
||||
flat
|
||||
style="cursor: pointer !important; "
|
||||
@click="getServiceToken(props.row)"
|
||||
/>
|
||||
<q-btn
|
||||
@click.stop="copyToClipboard(props.row.token)"
|
||||
size="sm"
|
||||
dense
|
||||
flat
|
||||
:title="t('serviceAccounts.copyToken')"
|
||||
icon="content_copy"
|
||||
:disable="!props.row.token"
|
||||
:class="{ 'disabled-opacity': !props.row.token }"
|
||||
class="copy-btn-sql"
|
||||
/>
|
||||
</div>
|
||||
<q-td :props="props" side>
|
||||
<span class="tw:font-mono">{{ props.row.token || '' }}</span>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
@@ -268,7 +228,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
no-caps
|
||||
class="no-border"
|
||||
color="primary"
|
||||
@click="refreshServiceToken(toBeRefreshed,false)"
|
||||
@click="refreshServiceToken(toBeRefreshed)"
|
||||
>
|
||||
{{ t("user.ok") }}
|
||||
</q-btn>
|
||||
@@ -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({
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.disabled-opacity {
|
||||
opacity: 0.1 !important;
|
||||
}
|
||||
.warning-text {
|
||||
color: #ec960c;
|
||||
}
|
||||
|
||||
@@ -78,45 +78,6 @@ describe("service_accounts service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("get_service_token", () => {
|
||||
it("should make GET request to fetch a specific service account by email", async () => {
|
||||
mockHttpInstance.get.mockResolvedValue({ data: { token: "abc123" } });
|
||||
|
||||
await service_accounts.get_service_token(
|
||||
"test-org",
|
||||
"svc@example.com"
|
||||
);
|
||||
|
||||
expect(mockHttpInstance.get).toHaveBeenCalledWith(
|
||||
"/api/test-org/service_accounts/svc@example.com"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle different email formats", async () => {
|
||||
mockHttpInstance.get.mockResolvedValue({ data: {} });
|
||||
|
||||
const emails = [
|
||||
"svc@example.com",
|
||||
"service.account@company.org",
|
||||
"bot+tag@domain.io",
|
||||
];
|
||||
for (const email of emails) {
|
||||
await service_accounts.get_service_token("test-org", email);
|
||||
expect(mockHttpInstance.get).toHaveBeenCalledWith(
|
||||
`/api/test-org/service_accounts/${email}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should propagate errors", async () => {
|
||||
mockHttpInstance.get.mockRejectedValue(new Error("Not found"));
|
||||
|
||||
await expect(
|
||||
service_accounts.get_service_token("test-org", "missing@example.com")
|
||||
).rejects.toThrow("Not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should make POST request with data payload", async () => {
|
||||
const data = { name: "my-service-account", role: "viewer" };
|
||||
@@ -322,7 +283,6 @@ describe("service_accounts service", () => {
|
||||
|
||||
await service_accounts.create(data, "test-org");
|
||||
await service_accounts.list("test-org");
|
||||
await service_accounts.get_service_token("test-org", email);
|
||||
await service_accounts.refresh_token("test-org", email);
|
||||
await service_accounts.delete("test-org", email);
|
||||
|
||||
@@ -333,9 +293,6 @@ describe("service_accounts service", () => {
|
||||
expect(mockHttpInstance.get).toHaveBeenCalledWith(
|
||||
"/api/test-org/service_accounts"
|
||||
);
|
||||
expect(mockHttpInstance.get).toHaveBeenCalledWith(
|
||||
`/api/test-org/service_accounts/${email}`
|
||||
);
|
||||
expect(mockHttpInstance.put).toHaveBeenCalledWith(
|
||||
`/api/test-org/service_accounts/${email}?rotateToken=true`,
|
||||
{}
|
||||
|
||||
@@ -25,14 +25,6 @@ const service_accounts = {
|
||||
`/api/${org_identifier}/service_accounts`
|
||||
);
|
||||
},
|
||||
get_service_token: (
|
||||
org_identifier: string,
|
||||
email_id: string
|
||||
) => {
|
||||
return http().get(
|
||||
`/api/${org_identifier}/service_accounts/${email_id}`
|
||||
);
|
||||
},
|
||||
create: (data: any, org_identifier: string) => {
|
||||
return http().post(`/api/${org_identifier}/service_accounts`, data);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user