diff --git a/bin/core/src/api/write/mod.rs b/bin/core/src/api/write/mod.rs index 7eb880cdd1..569122d871 100644 --- a/bin/core/src/api/write/mod.rs +++ b/bin/core/src/api/write/mod.rs @@ -179,6 +179,9 @@ pub enum WriteRequest { CreateApiKeyForServiceUser(CreateApiKeyForServiceUser), DeleteApiKeyForServiceUser(DeleteApiKeyForServiceUser), + // ==== API KEY ==== + RotateApiKey(RotateApiKey), + // ==== USER GROUP ==== CreateUserGroup(CreateUserGroup), RenameUserGroup(RenameUserGroup), diff --git a/bin/core/src/api/write/service_user.rs b/bin/core/src/api/write/service_user.rs index 74bf7f6743..2021469d3a 100644 --- a/bin/core/src/api/write/service_user.rs +++ b/bin/core/src/api/write/service_user.rs @@ -223,3 +223,65 @@ impl Resolve for DeleteApiKeyForServiceUser { Ok(DeleteApiKeyForServiceUserResponse {}) } } + +impl Resolve for RotateApiKey { + #[instrument( + "RotateApiKey", + skip_all, + fields( + operator = user.id, + key = self.key, + ) + )] + async fn resolve( + self, + WriteArgs { user }: &WriteArgs, + ) -> mogh_error::Result { + let db = db_client(); + + let api_key = db + .api_keys + .find_one(doc! { "key": &self.key }) + .await + .context("failed to query db for api key")? + .context("did not find matching api key")?; + + if api_key.user_id != user.id { + if !user.admin { + return Err( + anyhow!("Can only rotate your own api keys") + .status_code(StatusCode::FORBIDDEN), + ); + } + + let owner = find_one_by_id(&db.users, &api_key.user_id) + .await + .context("failed to query db for user")? + .context("no user found with id")?; + + let UserConfig::Service { .. } = &owner.config else { + return Err( + anyhow!("Admins can only rotate Service User api keys") + .status_code(StatusCode::FORBIDDEN), + ); + }; + } + + let res = create_api_key( + &KomodoAuthImpl, + api_key.user_id, + CreateApiKey { + name: api_key.name, + expires: api_key.expires as u64, + }, + ) + .await?; + + db.api_keys + .delete_one(doc! { "key": self.key }) + .await + .context("Created new api key, but failed to delete the old one. Delete it manually.")?; + + Ok(res) + } +} diff --git a/client/core/rs/src/api/write/api_key.rs b/client/core/rs/src/api/write/api_key.rs index 735911862c..67991da0ea 100644 --- a/client/core/rs/src/api/write/api_key.rs +++ b/client/core/rs/src/api/write/api_key.rs @@ -71,3 +71,36 @@ pub struct DeleteApiKeyForServiceUser { #[typeshare] pub type DeleteApiKeyForServiceUserResponse = NoData; + +// + +#[cfg(feature = "utoipa")] +#[utoipa::path( + post, + path = "/RotateApiKey", + description = "Rotate an api key. Generates a new key / secret pair with the same name and expiry, and deletes the old one. Users can rotate their own api keys. Admins can also rotate service user api keys.", + request_body(content = RotateApiKey), + responses( + (status = 200, description = "The new api key and secret"), + ), +)] +pub fn rotate_api_key() {} + +/// Rotate an api key. Generates a new key / secret pair +/// with the same name and expiry, and deletes the old one. +/// Users can rotate their own api keys. +/// Admins can also rotate service user api keys. +/// Response: [CreateApiKeyResponse]. +#[typeshare] +#[derive(Serialize, Deserialize, Debug, Clone, Resolve)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[empty_traits(KomodoWriteRequest)] +#[response(RotateApiKeyResponse)] +#[error(mogh_error::Error)] +pub struct RotateApiKey { + /// The api key to rotate + pub key: String, +} + +#[typeshare] +pub type RotateApiKeyResponse = CreateApiKeyResponse; diff --git a/client/core/ts/src/responses.ts b/client/core/ts/src/responses.ts index 46bbe9aacb..4f15278553 100644 --- a/client/core/ts/src/responses.ts +++ b/client/core/ts/src/responses.ts @@ -309,6 +309,7 @@ export type WriteResponses = { UpdateServiceUserDescription: Types.UpdateServiceUserDescriptionResponse; CreateApiKeyForServiceUser: Types.CreateApiKeyForServiceUserResponse; DeleteApiKeyForServiceUser: Types.DeleteApiKeyForServiceUserResponse; + RotateApiKey: Types.RotateApiKeyResponse; // ==== USER GROUP ==== CreateUserGroup: Types.UserGroup; diff --git a/client/core/ts/src/types.ts b/client/core/ts/src/types.ts index cb7f0c110f..de0aed757d 100644 --- a/client/core/ts/src/types.ts +++ b/client/core/ts/src/types.ts @@ -6724,6 +6724,20 @@ export interface CreateApiKeyForServiceUser { expires?: I64; } +/** + * Rotate an api key. Generates a new key / secret pair + * with the same name and expiry, and deletes the old one. + * Users can rotate their own api keys. + * Admins can also rotate service user api keys. + * Response: [CreateApiKeyResponse]. + */ +export interface RotateApiKey { + /** The api key to rotate */ + key: string; +} + +export type RotateApiKeyResponse = CreateApiKeyResponse; + /** Create a build. Response: [Build]. */ export interface CreateBuild { /** The name given to newly created build. */ @@ -11107,6 +11121,7 @@ export type WriteRequest = | { type: "UpdateServiceUserDescription", params: UpdateServiceUserDescription } | { type: "CreateApiKeyForServiceUser", params: CreateApiKeyForServiceUser } | { type: "DeleteApiKeyForServiceUser", params: DeleteApiKeyForServiceUser } + | { type: "RotateApiKey", params: RotateApiKey } | { type: "CreateUserGroup", params: CreateUserGroup } | { type: "RenameUserGroup", params: RenameUserGroup } | { type: "DeleteUserGroup", params: DeleteUserGroup } diff --git a/ui/src/components/api-keys/section.tsx b/ui/src/components/api-keys/section.tsx index 7c9c761915..4278d29895 100644 --- a/ui/src/components/api-keys/section.tsx +++ b/ui/src/components/api-keys/section.tsx @@ -4,7 +4,10 @@ import NewApiKey from "./new"; import ApiKeysTable from "./table"; import { useInvalidate, useRead, useWrite } from "@/lib/hooks"; import { notifications } from "@mantine/notifications"; -import { Box } from "@mantine/core"; +import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core"; +import { CopyText } from "mogh_ui"; +import { Types } from "komodo_client"; +import { useState } from "react"; export interface ApiKeysSectionProps extends SectionProps { /** For service user api keys */ @@ -42,6 +45,16 @@ export default function ApiKeysSection({ }, }, ); + const [rotated, setRotated] = useState(); + const { mutate: rotate, isPending: rotatePending } = useWrite( + "RotateApiKey", + { + onSuccess: (res) => { + inv([userId ? "ListApiKeysForServiceUser" : "ListApiKeys"]); + setRotated(res); + }, + }, + ); return (
rotate({ key })} + rotatePending={rotatePending} onDelete={(key) => userId ? serviceDelete({ key }) : regularDelete({ key }) } deletePending={userId ? servicePending : regularPending} /> )} + + setRotated(undefined)} + title={API Key Rotated} + > + {rotated && ( + + + Copy the new API key and secret.{" "} + The secret will not be shown again. + + + + Key + + + + + Secret + + + + + + + + )} +
); } diff --git a/ui/src/components/api-keys/table.tsx b/ui/src/components/api-keys/table.tsx index 7496da4a7c..3d1a37f1fa 100644 --- a/ui/src/components/api-keys/table.tsx +++ b/ui/src/components/api-keys/table.tsx @@ -9,6 +9,8 @@ const ONE_DAY_MS = 1000 * 60 * 60 * 24; export interface ApiKeysTableProps { keys: Types.ApiKey[]; + onRotate: (key: string) => void; + rotatePending: boolean; onDelete: (key: string) => void; deletePending: boolean; noBorder?: boolean; @@ -16,6 +18,8 @@ export interface ApiKeysTableProps { export default function ApiKeysTable({ keys, + onRotate, + rotatePending, onDelete, deletePending, noBorder, @@ -60,6 +64,18 @@ export default function ApiKeysTable({ ); }, }, + { + header: "Rotate", + cell: ({ row }) => ( + } + onClick={() => onRotate(row.original.key)} + loading={rotatePending} + > + Rotate + + ), + }, { header: "Delete", cell: ({ row }) => (