From 2a103b3209b31cbf4065ea546147e3e084104240 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Thu, 23 Feb 2023 15:50:31 +0000 Subject: [PATCH 01/23] add tokens switch --- .../TokensPage/TokensPage.tsx | 36 +++++++++++++++++-- .../UserSettingsPage/TokensPage/hooks.ts | 30 +++++++++++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx index 34612c180a6a9..aee8854ca24e7 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx +++ b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx @@ -5,9 +5,16 @@ import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" import { Typography } from "components/Typography/Typography" import makeStyles from "@material-ui/core/styles/makeStyles" import { useTranslation } from "react-i18next" -import { useTokensData, useDeleteToken } from "./hooks" +import { + useTokensData, + useDeleteToken, + useCheckTokenPermissions, +} from "./hooks" import { displaySuccess, displayError } from "components/GlobalSnackbar/utils" import { getErrorMessage } from "api/errors" +import Switch from "@material-ui/core/Switch" +import FormGroup from "@material-ui/core/FormGroup" +import FormControlLabel from "@material-ui/core/FormControlLabel" export const TokensPage: FC> = () => { const styles = useStyles() @@ -15,6 +22,8 @@ export const TokensPage: FC> = () => { const [tokenIdToDelete, setTokenIdToDelete] = useState( undefined, ) + const [viewAllTokens, setViewAllTokens] = useState(false) + const { data: perms } = useCheckTokenPermissions() const { data: tokens, @@ -23,7 +32,7 @@ export const TokensPage: FC> = () => { isFetched, queryKey, } = useTokensData({ - include_all: true, + include_all: viewAllTokens, }) const { mutate: deleteToken, isLoading: isDeleting } = @@ -59,6 +68,22 @@ export const TokensPage: FC> = () => { return ( <>
+ + {perms?.readAllApiKeys && ( + setViewAllTokens(!viewAllTokens)} + name="viewAllTokens" + color="primary" + /> + } + label={t("toggleLabel")} + /> + )} + ({ justifyContent: "end", marginBottom: "10px", }, + selectAllSwitch: { + // decrease the hover state on the switch + // so that it isn't hidden behind the container + "& .MuiIconButton-root": { + padding: "8px", + }, + }, })) export default TokensPage diff --git a/site/src/pages/UserSettingsPage/TokensPage/hooks.ts b/site/src/pages/UserSettingsPage/TokensPage/hooks.ts index a739d95bf98b4..77acb76087c38 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/hooks.ts +++ b/site/src/pages/UserSettingsPage/TokensPage/hooks.ts @@ -4,9 +4,36 @@ import { useQueryClient, QueryKey, } from "@tanstack/react-query" -import { getTokens, deleteAPIKey } from "api/api" +import { getTokens, deleteAPIKey, checkAuthorization } from "api/api" import { TokensFilter } from "api/typesGenerated" +// Owners have the ability to read all API tokens, +// whereas members can only see the tokens they have created. +// We check permissions here to determine whether to display the +// 'View All' switch on the TokensPage +export const useCheckTokenPermissions = () => { + const queryKey = ["auth"] + const params = { + checks: { + readAllApiKeys: { + object: { + resource_type: "api_key", + }, + action: "read", + }, + }, + } + const result = useQuery({ + queryKey, + queryFn: () => checkAuthorization(params), + }) + + return { + ...result, + } +} + +// Load all tokens export const useTokensData = ({ include_all }: TokensFilter) => { const queryKey = ["tokens", include_all] const result = useQuery({ @@ -23,6 +50,7 @@ export const useTokensData = ({ include_all }: TokensFilter) => { } } +// Delete a token export const useDeleteToken = (queryKey: QueryKey) => { const queryClient = useQueryClient() From 640274509402d0bbe2fd894aad56b644f90e08f8 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Thu, 23 Feb 2023 16:30:13 +0000 Subject: [PATCH 02/23] reorged TokensPage --- .../TokensPage/TokensPage.tsx | 108 ++++-------------- .../components/ConfirmDeleteDialog.tsx | 59 ++++++++++ .../TokensPage/components/TokensSwitch.tsx | 48 ++++++++ .../TokensPage/components/index.ts | 2 + 4 files changed, 129 insertions(+), 88 deletions(-) create mode 100644 site/src/pages/UserSettingsPage/TokensPage/components/ConfirmDeleteDialog.tsx create mode 100644 site/src/pages/UserSettingsPage/TokensPage/components/TokensSwitch.tsx create mode 100644 site/src/pages/UserSettingsPage/TokensPage/components/index.ts diff --git a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx index aee8854ca24e7..d198894dd5e0f 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx +++ b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx @@ -1,24 +1,22 @@ import { FC, PropsWithChildren, useState } from "react" -import { Section } from "../../../components/SettingsLayout/Section" +import { Section } from "components/SettingsLayout/Section" import { TokensPageView } from "./TokensPageView" -import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" -import { Typography } from "components/Typography/Typography" import makeStyles from "@material-ui/core/styles/makeStyles" import { useTranslation } from "react-i18next" -import { - useTokensData, - useDeleteToken, - useCheckTokenPermissions, -} from "./hooks" -import { displaySuccess, displayError } from "components/GlobalSnackbar/utils" -import { getErrorMessage } from "api/errors" -import Switch from "@material-ui/core/Switch" -import FormGroup from "@material-ui/core/FormGroup" -import FormControlLabel from "@material-ui/core/FormControlLabel" +import { useTokensData, useCheckTokenPermissions } from "./hooks" +import { TokensSwitch, ConfirmDeleteDialog } from "./components" export const TokensPage: FC> = () => { const styles = useStyles() const { t } = useTranslation("tokensPage") + + const description = ( + <> + {t("description")}{" "} + coder tokens create command. + + ) + const [tokenIdToDelete, setTokenIdToDelete] = useState( undefined, ) @@ -35,55 +33,14 @@ export const TokensPage: FC> = () => { include_all: viewAllTokens, }) - const { mutate: deleteToken, isLoading: isDeleting } = - useDeleteToken(queryKey) - - const onDeleteSuccess = () => { - displaySuccess(t("deleteToken.deleteSuccess")) - setTokenIdToDelete(undefined) - } - - const onDeleteError = (error: unknown) => { - const message = getErrorMessage(error, t("deleteToken.deleteFailure")) - displayError(message) - setTokenIdToDelete(undefined) - } - - const description = ( - <> - {t("description")}{" "} - coder tokens create command. - - ) - - const content = ( - - {t("deleteToken.deleteCaption")} -
-
- {tokenIdToDelete} -
- ) - return ( <>
- - {perms?.readAllApiKeys && ( - setViewAllTokens(!viewAllTokens)} - name="viewAllTokens" - color="primary" - /> - } - label={t("toggleLabel")} - /> - )} - + > = () => { }} />
- - { - if (!tokenIdToDelete) { - return - } - deleteToken(tokenIdToDelete, { - onError: onDeleteError, - onSuccess: onDeleteSuccess, - }) - }} - onClose={() => { - setTokenIdToDelete(undefined) - }} + ) @@ -125,17 +68,6 @@ const useStyles = makeStyles((theme) => ({ color: theme.palette.text.primary, borderRadius: 2, }, - formRow: { - justifyContent: "end", - marginBottom: "10px", - }, - selectAllSwitch: { - // decrease the hover state on the switch - // so that it isn't hidden behind the container - "& .MuiIconButton-root": { - padding: "8px", - }, - }, })) export default TokensPage diff --git a/site/src/pages/UserSettingsPage/TokensPage/components/ConfirmDeleteDialog.tsx b/site/src/pages/UserSettingsPage/TokensPage/components/ConfirmDeleteDialog.tsx new file mode 100644 index 0000000000000..b9c6c483bb6b0 --- /dev/null +++ b/site/src/pages/UserSettingsPage/TokensPage/components/ConfirmDeleteDialog.tsx @@ -0,0 +1,59 @@ +import { FC } from "react" +import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" +import { useTranslation } from "react-i18next" +import { Typography } from "components/Typography/Typography" +import { useDeleteToken } from "../hooks" +import { displaySuccess, displayError } from "components/GlobalSnackbar/utils" +import { getErrorMessage } from "api/errors" + +export const ConfirmDeleteDialog: FC<{ + queryKey: (string | boolean)[] + tokenId: string | undefined + setTokenId: (arg: string | undefined) => void +}> = ({ queryKey, tokenId, setTokenId }) => { + const { t } = useTranslation("tokensPage") + + const content = ( + + {t("deleteToken.deleteCaption")} +
+
+ {tokenId} +
+ ) + + const { mutate: deleteToken, isLoading: isDeleting } = + useDeleteToken(queryKey) + + const onDeleteSuccess = () => { + displaySuccess(t("deleteToken.deleteSuccess")) + setTokenId(undefined) + } + + const onDeleteError = (error: unknown) => { + const message = getErrorMessage(error, t("deleteToken.deleteFailure")) + displayError(message) + setTokenId(undefined) + } + + return ( + { + if (!tokenId) { + return + } + deleteToken(tokenId, { + onError: onDeleteError, + onSuccess: onDeleteSuccess, + }) + }} + onClose={() => { + setTokenId(undefined) + }} + /> + ) +} diff --git a/site/src/pages/UserSettingsPage/TokensPage/components/TokensSwitch.tsx b/site/src/pages/UserSettingsPage/TokensPage/components/TokensSwitch.tsx new file mode 100644 index 0000000000000..516559f05b597 --- /dev/null +++ b/site/src/pages/UserSettingsPage/TokensPage/components/TokensSwitch.tsx @@ -0,0 +1,48 @@ +import { FC } from "react" +import Switch from "@material-ui/core/Switch" +import FormGroup from "@material-ui/core/FormGroup" +import FormControlLabel from "@material-ui/core/FormControlLabel" +import makeStyles from "@material-ui/core/styles/makeStyles" +import { useTranslation } from "react-i18next" + +export const TokensSwitch: FC<{ + hasReadAll: boolean + viewAllTokens: boolean + setViewAllTokens: (arg: boolean) => void +}> = ({ hasReadAll, viewAllTokens, setViewAllTokens }) => { + const styles = useStyles() + const { t } = useTranslation("tokensPage") + + return ( + + {hasReadAll && ( + setViewAllTokens(!viewAllTokens)} + name="viewAllTokens" + color="primary" + /> + } + label={t("toggleLabel")} + /> + )} + + ) +} + +const useStyles = makeStyles(() => ({ + formRow: { + justifyContent: "end", + marginBottom: "10px", + }, + selectAllSwitch: { + // decrease the hover state on the switch + // so that it isn't hidden behind the container + "& .MuiIconButton-root": { + padding: "8px", + }, + }, +})) diff --git a/site/src/pages/UserSettingsPage/TokensPage/components/index.ts b/site/src/pages/UserSettingsPage/TokensPage/components/index.ts new file mode 100644 index 0000000000000..e58a46323c61a --- /dev/null +++ b/site/src/pages/UserSettingsPage/TokensPage/components/index.ts @@ -0,0 +1,2 @@ +export { ConfirmDeleteDialog } from "./ConfirmDeleteDialog" +export { TokensSwitch } from "./TokensSwitch" From 340a9d15c9e0322a31518229d25169421328d172 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Thu, 23 Feb 2023 16:56:25 +0000 Subject: [PATCH 03/23] using Trans component for description --- site/src/i18n/en/tokensPage.json | 2 +- .../TokensPage/TokensPage.tsx | 32 ++++++++++++------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/site/src/i18n/en/tokensPage.json b/site/src/i18n/en/tokensPage.json index 4012a2cb5c4ae..b071c1b44e979 100644 --- a/site/src/i18n/en/tokensPage.json +++ b/site/src/i18n/en/tokensPage.json @@ -1,6 +1,6 @@ { "title": "Tokens", - "description": "Tokens are used to authenticate with the Coder API. You can create a token with the Coder CLI using the ", + "description": "Tokens are used to authenticate with the Coder API. You can create a token with the Coder CLI using the <1>{{cliCreateCommand}} command.", "emptyState": "No tokens found", "deleteToken": { "delete": "Delete Token", diff --git a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx index d198894dd5e0f..ab7d03a13a64c 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx +++ b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx @@ -2,7 +2,7 @@ import { FC, PropsWithChildren, useState } from "react" import { Section } from "components/SettingsLayout/Section" import { TokensPageView } from "./TokensPageView" import makeStyles from "@material-ui/core/styles/makeStyles" -import { useTranslation } from "react-i18next" +import { useTranslation, Trans } from "react-i18next" import { useTokensData, useCheckTokenPermissions } from "./hooks" import { TokensSwitch, ConfirmDeleteDialog } from "./components" @@ -10,11 +10,12 @@ export const TokensPage: FC> = () => { const styles = useStyles() const { t } = useTranslation("tokensPage") + const cliCreateCommand = "coder tokens create" const description = ( - <> - {t("description")}{" "} - coder tokens create command. - + + Tokens are used to authenticate with the Coder API. You can create a token + with the Coder CLI using the {{ cliCreateCommand }} command. + ) const [tokenIdToDelete, setTokenIdToDelete] = useState( @@ -35,7 +36,12 @@ export const TokensPage: FC> = () => { return ( <> -
+
> = () => { } const useStyles = makeStyles((theme) => ({ - code: { - background: theme.palette.divider, - fontSize: 12, - padding: "2px 4px", - color: theme.palette.text.primary, - borderRadius: 2, + section: { + "& code": { + background: theme.palette.divider, + fontSize: 12, + padding: "2px 4px", + color: theme.palette.text.primary, + borderRadius: 2, + }, }, })) From ff2ac685d124f885eeee36daac502550017efd8d Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Thu, 23 Feb 2023 17:17:24 +0000 Subject: [PATCH 04/23] using Trans component on DeleteDialog --- site/src/i18n/en/tokensPage.json | 2 +- .../TokensPage/components/ConfirmDeleteDialog.tsx | 15 +++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/site/src/i18n/en/tokensPage.json b/site/src/i18n/en/tokensPage.json index b071c1b44e979..024edfaea94ca 100644 --- a/site/src/i18n/en/tokensPage.json +++ b/site/src/i18n/en/tokensPage.json @@ -4,7 +4,7 @@ "emptyState": "No tokens found", "deleteToken": { "delete": "Delete Token", - "deleteCaption": "Are you sure you want to delete this token?", + "deleteCaption": "Are you sure you want to delete this token?

<4>{{tokenId}}", "deleteSuccess": "Token has been deleted", "deleteFailure": "Failed to delete token" }, diff --git a/site/src/pages/UserSettingsPage/TokensPage/components/ConfirmDeleteDialog.tsx b/site/src/pages/UserSettingsPage/TokensPage/components/ConfirmDeleteDialog.tsx index b9c6c483bb6b0..4e34aa524399f 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/components/ConfirmDeleteDialog.tsx +++ b/site/src/pages/UserSettingsPage/TokensPage/components/ConfirmDeleteDialog.tsx @@ -1,7 +1,6 @@ import { FC } from "react" import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" -import { useTranslation } from "react-i18next" -import { Typography } from "components/Typography/Typography" +import { useTranslation, Trans } from "react-i18next" import { useDeleteToken } from "../hooks" import { displaySuccess, displayError } from "components/GlobalSnackbar/utils" import { getErrorMessage } from "api/errors" @@ -13,13 +12,13 @@ export const ConfirmDeleteDialog: FC<{ }> = ({ queryKey, tokenId, setTokenId }) => { const { t } = useTranslation("tokensPage") - const content = ( - - {t("deleteToken.deleteCaption")} + const description = ( + + Are you sure you want to delete this token?

- {tokenId} -
+ {{ tokenId }} + ) const { mutate: deleteToken, isLoading: isDeleting } = @@ -39,7 +38,7 @@ export const ConfirmDeleteDialog: FC<{ return ( { From ade9548a62471cb32122fcdb3d968d62c6eec238 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Thu, 23 Feb 2023 18:33:45 +0000 Subject: [PATCH 05/23] add owner col --- coderd/apikey.go | 20 ++++++++++++-- site/src/i18n/en/tokensPage.json | 3 ++- .../TokensPage/TokensPage.tsx | 1 + .../TokensPage/TokensPageView.tsx | 27 +++++++++++++++---- .../UserSettingsPage/TokensPage/hooks.ts | 2 +- 5 files changed, 44 insertions(+), 9 deletions(-) diff --git a/coderd/apikey.go b/coderd/apikey.go index ef0921d037e0c..6832d56f33431 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -167,6 +167,11 @@ func (api *API) apiKey(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, convertAPIKey(key)) } +type ConvertedAPIKey struct { + codersdk.APIKey + Username string `json:"username"` +} + // @Summary Get user tokens // @ID get-user-tokens // @Security CoderSessionToken @@ -216,9 +221,20 @@ func (api *API) tokens(rw http.ResponseWriter, r *http.Request) { return } - var apiKeys []codersdk.APIKey + var apiKeys []ConvertedAPIKey for _, key := range keys { - apiKeys = append(apiKeys, convertAPIKey(key)) + user, err := api.Database.GetUserByID(ctx, key.UserID) + if err != nil { + apiKeys = append(apiKeys, ConvertedAPIKey{ + APIKey: convertAPIKey(key), + Username: "", + }) + } else { + apiKeys = append(apiKeys, ConvertedAPIKey{ + APIKey: convertAPIKey(key), + Username: user.Username, + }) + } } httpapi.Write(ctx, rw, http.StatusOK, apiKeys) diff --git a/site/src/i18n/en/tokensPage.json b/site/src/i18n/en/tokensPage.json index 024edfaea94ca..a6b4cc3f94d7e 100644 --- a/site/src/i18n/en/tokensPage.json +++ b/site/src/i18n/en/tokensPage.json @@ -13,6 +13,7 @@ "id": "ID", "createdAt": "Created At", "lastUsed": "Last Used", - "expiresAt": "Expires At" + "expiresAt": "Expires At", + "owner": "Owner" } } diff --git a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx index ab7d03a13a64c..b7af557668d7a 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx +++ b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx @@ -49,6 +49,7 @@ export const TokensPage: FC> = () => { /> { return now.isBefore(t.add(100, "year")) ? t.fromNow() : "Never" } +interface ConvertedAPIKey extends APIKey { + username: string +} + export interface TokensPageViewProps { - tokens?: APIKey[] + tokens?: ConvertedAPIKey[] + viewAllTokens: boolean getTokensError?: Error | unknown isLoading: boolean hasLoaded: boolean @@ -36,6 +41,7 @@ export const TokensPageView: FC< React.PropsWithChildren > = ({ tokens, + viewAllTokens, getTokensError, isLoading, hasLoaded, @@ -44,6 +50,7 @@ export const TokensPageView: FC< }) => { const theme = useTheme() const { t } = useTranslation("tokensPage") + const colWidth = viewAllTokens ? "20%" : "25%" return ( @@ -57,10 +64,13 @@ export const TokensPageView: FC< - {t("table.id")} - {t("table.createdAt")} - {t("table.lastUsed")} - {t("table.expiresAt")} + {t("table.id")} + {t("table.createdAt")} + {t("table.lastUsed")} + {t("table.expiresAt")} + {viewAllTokens && ( + {t("table.owner")} + )} @@ -102,6 +112,13 @@ export const TokensPageView: FC< {dayjs(token.expires_at).fromNow()} + {viewAllTokens && ( + + + {token.username} + + + )} { const queryKey = ["auth"] const params = { From dd587e86fbafb97fa7302df77f4daa4c72ad82dc Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Thu, 23 Feb 2023 18:42:34 +0000 Subject: [PATCH 06/23] simplify hook return --- site/src/pages/UserSettingsPage/TokensPage/hooks.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/site/src/pages/UserSettingsPage/TokensPage/hooks.ts b/site/src/pages/UserSettingsPage/TokensPage/hooks.ts index 281d4bafaf35d..ec69a8a59ca8c 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/hooks.ts +++ b/site/src/pages/UserSettingsPage/TokensPage/hooks.ts @@ -23,14 +23,10 @@ export const useCheckTokenPermissions = () => { }, }, } - const result = useQuery({ + return useQuery({ queryKey, queryFn: () => checkAuthorization(params), }) - - return { - ...result, - } } // Load all tokens From 2ce518c02327ff4f39b4e0b1df85425f8bd49994 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Fri, 24 Feb 2023 16:22:27 +0000 Subject: [PATCH 07/23] lint --- .vscode/settings.json | 1 + site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx | 7 ++++++- .../pages/UserSettingsPage/TokensPage/TokensPageView.tsx | 6 +----- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 18adff1b26787..d4c34f3a9a106 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,7 @@ "agentsdk", "apps", "ASKPASS", + "authcheck", "autostop", "awsidentity", "bodyclose", diff --git a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx index b7af557668d7a..9d740db5022b4 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx +++ b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx @@ -5,6 +5,11 @@ import makeStyles from "@material-ui/core/styles/makeStyles" import { useTranslation, Trans } from "react-i18next" import { useTokensData, useCheckTokenPermissions } from "./hooks" import { TokensSwitch, ConfirmDeleteDialog } from "./components" +import { APIKey } from "api/typesGenerated" + +export interface ConvertedAPIKey extends APIKey { + username: string +} export const TokensPage: FC> = () => { const styles = useStyles() @@ -48,7 +53,7 @@ export const TokensPage: FC> = () => { setViewAllTokens={setViewAllTokens} /> { const t = dayjs(lastUsed) @@ -23,10 +23,6 @@ const lastUsedOrNever = (lastUsed: string) => { return now.isBefore(t.add(100, "year")) ? t.fromNow() : "Never" } -interface ConvertedAPIKey extends APIKey { - username: string -} - export interface TokensPageViewProps { tokens?: ConvertedAPIKey[] viewAllTokens: boolean From 740c4a2d4ad7371523c71f1123f57cfeb926b2b8 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Fri, 24 Feb 2023 18:31:01 +0000 Subject: [PATCH 08/23] type for response --- cli/tokens.go | 21 ++++--------------- coderd/apikey.go | 11 +++------- codersdk/apikey.go | 9 ++++++-- site/src/api/api.ts | 4 ++-- site/src/api/typesGenerated.ts | 5 +++++ .../TokensPage/TokensPage.tsx | 7 +------ .../TokensPage/TokensPageView.tsx | 2 +- 7 files changed, 23 insertions(+), 36 deletions(-) diff --git a/cli/tokens.go b/cli/tokens.go index 427e45606fcc3..1fcbde1d6a1ee 100644 --- a/cli/tokens.go +++ b/cli/tokens.go @@ -6,7 +6,6 @@ import ( "strings" "time" - "github.com/google/uuid" "github.com/spf13/cobra" "golang.org/x/exp/slices" "golang.org/x/xerrors" @@ -99,16 +98,14 @@ type tokenListRow struct { Owner string `json:"-" table:"owner"` } -func tokenListRowFromToken(token codersdk.APIKey, usersByID map[uuid.UUID]codersdk.User) tokenListRow { - user := usersByID[token.UserID] - +func tokenListRowFromToken(token codersdk.ConvertedAPIKey) tokenListRow { return tokenListRow{ - APIKey: token, + APIKey: token.APIKey, ID: token.ID, LastUsed: token.LastUsed, ExpiresAt: token.ExpiresAt, CreatedAt: token.CreatedAt, - Owner: user.Username, + Owner: token.Username, } } @@ -150,20 +147,10 @@ func listTokens() *cobra.Command { )) } - userRes, err := client.Users(cmd.Context(), codersdk.UsersRequest{}) - if err != nil { - return err - } - - usersByID := map[uuid.UUID]codersdk.User{} - for _, user := range userRes.Users { - usersByID[user.ID] = user - } - displayTokens = make([]tokenListRow, len(tokens)) for i, token := range tokens { - displayTokens[i] = tokenListRowFromToken(token, usersByID) + displayTokens[i] = tokenListRowFromToken(token) } out, err := formatter.Format(cmd.Context(), displayTokens) diff --git a/coderd/apikey.go b/coderd/apikey.go index 6832d56f33431..2ce15ef6135f0 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -167,11 +167,6 @@ func (api *API) apiKey(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, convertAPIKey(key)) } -type ConvertedAPIKey struct { - codersdk.APIKey - Username string `json:"username"` -} - // @Summary Get user tokens // @ID get-user-tokens // @Security CoderSessionToken @@ -221,16 +216,16 @@ func (api *API) tokens(rw http.ResponseWriter, r *http.Request) { return } - var apiKeys []ConvertedAPIKey + var apiKeys []codersdk.ConvertedAPIKey for _, key := range keys { user, err := api.Database.GetUserByID(ctx, key.UserID) if err != nil { - apiKeys = append(apiKeys, ConvertedAPIKey{ + apiKeys = append(apiKeys, codersdk.ConvertedAPIKey{ APIKey: convertAPIKey(key), Username: "", }) } else { - apiKeys = append(apiKeys, ConvertedAPIKey{ + apiKeys = append(apiKeys, codersdk.ConvertedAPIKey{ APIKey: convertAPIKey(key), Username: user.Username, }) diff --git a/codersdk/apikey.go b/codersdk/apikey.go index d84e3debbf406..c7e6bce133706 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -90,6 +90,11 @@ type TokensFilter struct { IncludeAll bool `json:"include_all"` } +type ConvertedAPIKey struct { + APIKey + Username string `json:"username"` +} + // asRequestOption returns a function that can be used in (*Client).Request. // It modifies the request query parameters. func (f TokensFilter) asRequestOption() RequestOption { @@ -101,7 +106,7 @@ func (f TokensFilter) asRequestOption() RequestOption { } // Tokens list machine API keys. -func (c *Client) Tokens(ctx context.Context, userID string, filter TokensFilter) ([]APIKey, error) { +func (c *Client) Tokens(ctx context.Context, userID string, filter TokensFilter) ([]ConvertedAPIKey, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/keys/tokens", userID), nil, filter.asRequestOption()) if err != nil { return nil, err @@ -110,7 +115,7 @@ func (c *Client) Tokens(ctx context.Context, userID string, filter TokensFilter) if res.StatusCode > http.StatusOK { return nil, ReadBodyAsError(res) } - apiKey := []APIKey{} + apiKey := []ConvertedAPIKey{} return apiKey, json.NewDecoder(res.Body).Decode(&apiKey) } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 303042f2ccf5e..2b9dbc32cd5a0 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -142,8 +142,8 @@ export const getApiKey = async (): Promise => { export const getTokens = async ( params: TypesGen.TokensFilter, -): Promise => { - const response = await axios.get( +): Promise => { + const response = await axios.get( `/api/v2/users/me/keys/tokens`, { params, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index cf36bd98bff3b..7849875f19b53 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -135,6 +135,11 @@ export interface ComputedParameter extends Parameter { readonly default_source_value: boolean } +// From codersdk/apikey.go +export interface ConvertedAPIKey extends APIKey { + readonly username: string +} + // From codersdk/users.go export interface CreateFirstUserRequest { readonly email: string diff --git a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx index 9d740db5022b4..b7af557668d7a 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx +++ b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx @@ -5,11 +5,6 @@ import makeStyles from "@material-ui/core/styles/makeStyles" import { useTranslation, Trans } from "react-i18next" import { useTokensData, useCheckTokenPermissions } from "./hooks" import { TokensSwitch, ConfirmDeleteDialog } from "./components" -import { APIKey } from "api/typesGenerated" - -export interface ConvertedAPIKey extends APIKey { - username: string -} export const TokensPage: FC> = () => { const styles = useStyles() @@ -53,7 +48,7 @@ export const TokensPage: FC> = () => { setViewAllTokens={setViewAllTokens} /> { const t = dayjs(lastUsed) From ef09103d62a93dd2859a7c0b06cd8041facb5136 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Mon, 27 Feb 2023 20:01:26 +0000 Subject: [PATCH 09/23] added flag for name --- cli/tokens.go | 15 ++++++++++---- coderd/apidoc/docs.go | 7 +++++++ coderd/apidoc/swagger.json | 7 +++++++ coderd/apikey.go | 20 +++++++++++++++++++ coderd/database/dump.sql | 6 +++++- .../000100_add_apikey_name.down.sql | 2 ++ .../migrations/000100_add_apikey_name.up.sql | 3 +++ coderd/database/models.go | 1 + coderd/database/queries.sql.go | 20 +++++++++++++------ coderd/database/queries/apikeys.sql | 5 +++-- coderd/database/unique_constraint.go | 1 + coderd/users.go | 1 + codersdk/apikey.go | 6 ++++-- docs/admin/audit-logs.md | 2 +- docs/api/schemas.md | 14 ++++++++----- docs/api/users.md | 6 +++++- docs/cli/coder_tokens.md | 10 +++++----- docs/cli/coder_tokens_create.md | 9 ++++++++- docs/cli/coder_tokens_list.md | 4 ++-- enterprise/audit/table.go | 1 + site/src/api/typesGenerated.ts | 2 ++ 21 files changed, 112 insertions(+), 30 deletions(-) create mode 100644 coderd/database/migrations/000100_add_apikey_name.down.sql create mode 100644 coderd/database/migrations/000100_add_apikey_name.up.sql diff --git a/cli/tokens.go b/cli/tokens.go index 1fcbde1d6a1ee..ca877c08dc7a3 100644 --- a/cli/tokens.go +++ b/cli/tokens.go @@ -49,10 +49,13 @@ func tokens() *cobra.Command { } func createToken() *cobra.Command { - var tokenLifetime time.Duration + var ( + tokenLifetime time.Duration + name string + ) cmd := &cobra.Command{ Use: "create", - Short: "Create a tokens", + Short: "Create a token", RunE: func(cmd *cobra.Command, args []string) error { client, err := CreateClient(cmd) if err != nil { @@ -60,7 +63,8 @@ func createToken() *cobra.Command { } res, err := client.CreateToken(cmd.Context(), codersdk.Me, codersdk.CreateTokenRequest{ - Lifetime: tokenLifetime, + Lifetime: tokenLifetime, + TokenName: name, }) if err != nil { return xerrors.Errorf("create tokens: %w", err) @@ -81,6 +85,7 @@ func createToken() *cobra.Command { } cliflag.DurationVarP(cmd.Flags(), &tokenLifetime, "lifetime", "", "CODER_TOKEN_LIFETIME", 30*24*time.Hour, "Specify a duration for the lifetime of the token.") + cmd.Flags().StringVarP(&name, "name", "n", "", "Specify a human-readable name.") return cmd } @@ -92,6 +97,7 @@ type tokenListRow struct { // For table format: ID string `json:"-" table:"id,default_sort"` + TokenName string `json:"token_name" table:"name"` LastUsed time.Time `json:"-" table:"last used"` ExpiresAt time.Time `json:"-" table:"expires at"` CreatedAt time.Time `json:"-" table:"created at"` @@ -102,6 +108,7 @@ func tokenListRowFromToken(token codersdk.ConvertedAPIKey) tokenListRow { return tokenListRow{ APIKey: token.APIKey, ID: token.ID, + TokenName: token.TokenName, LastUsed: token.LastUsed, ExpiresAt: token.ExpiresAt, CreatedAt: token.CreatedAt, @@ -111,7 +118,7 @@ func tokenListRowFromToken(token codersdk.ConvertedAPIKey) tokenListRow { func listTokens() *cobra.Command { // we only display the 'owner' column if the --all argument is passed in - defaultCols := []string{"id", "last used", "expires at", "created at"} + defaultCols := []string{"id", "name", "last used", "expires at", "created at"} if slices.Contains(os.Args, "-a") || slices.Contains(os.Args, "--all") { defaultCols = append(defaultCols, "owner") } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d4e1236849534..5a03ae3e082b0 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5284,6 +5284,7 @@ const docTemplate = `{ "lifetime_seconds", "login_type", "scope", + "token_name", "updated_at", "user_id" ], @@ -5330,6 +5331,9 @@ const docTemplate = `{ } ] }, + "token_name": { + "type": "string" + }, "updated_at": { "type": "string", "format": "date-time" @@ -5879,6 +5883,9 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.APIKeyScope" } ] + }, + "token_name": { + "type": "string" } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 894a2d0b6cafd..d525b9d275fb9 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4681,6 +4681,7 @@ "lifetime_seconds", "login_type", "scope", + "token_name", "updated_at", "user_id" ], @@ -4719,6 +4720,9 @@ } ] }, + "token_name": { + "type": "string" + }, "updated_at": { "type": "string", "format": "date-time" @@ -5214,6 +5218,9 @@ "$ref": "#/definitions/codersdk.APIKeyScope" } ] + }, + "token_name": { + "type": "string" } } }, diff --git a/coderd/apikey.go b/coderd/apikey.go index 2ce15ef6135f0..53b46dd08c62b 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -9,10 +9,12 @@ import ( "net" "net/http" "strconv" + "strings" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" + "github.com/moby/moby/pkg/namesgenerator" "github.com/tabbed/pqtype" "golang.org/x/xerrors" @@ -62,6 +64,12 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { lifeTime = createToken.Lifetime } + tokenName := namesgenerator.GetRandomName(1) + + if len(createToken.TokenName) != 0 { + tokenName = createToken.TokenName + } + err := api.validateAPIKeyLifetime(lifeTime) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -77,8 +85,18 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { ExpiresAt: database.Now().Add(lifeTime), Scope: scope, LifetimeSeconds: int64(lifeTime.Seconds()), + TokenName: tokenName, }) if err != nil { + if strings.Contains(err.Error(), "duplicate key value violates unique constraint") { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: fmt.Sprintf("A token with name %q already exists.", tokenName), + Validations: []codersdk.ValidationError{{ + Field: "name", + Detail: "This value is already in use and should be unique.", + }}, + }) + } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to create API key.", Detail: err.Error(), @@ -295,6 +313,7 @@ type createAPIKeyParams struct { ExpiresAt time.Time LifetimeSeconds int64 Scope database.APIKeyScope + TokenName string } func (api *API) validateAPIKeyLifetime(lifetime time.Duration) error { @@ -364,6 +383,7 @@ func (api *API) createAPIKey(ctx context.Context, params createAPIKeyParams) (*h HashedSecret: hashed[:], LoginType: params.LoginType, Scope: scope, + TokenName: params.TokenName, }) if err != nil { return nil, nil, xerrors.Errorf("insert API key: %w", err) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 2137be816ef84..04301f2b08086 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -144,7 +144,8 @@ CREATE TABLE api_keys ( login_type login_type NOT NULL, lifetime_seconds bigint DEFAULT 86400 NOT NULL, ip_address inet DEFAULT '0.0.0.0'::inet NOT NULL, - scope api_key_scope DEFAULT 'all'::api_key_scope NOT NULL + scope api_key_scope DEFAULT 'all'::api_key_scope NOT NULL, + token_name text DEFAULT ''::text NOT NULL ); COMMENT ON COLUMN api_keys.hashed_secret IS 'hashed_secret contains a SHA256 hash of the key secret. This is considered a secret and MUST NOT be returned from the API as it is used for API key encryption in app proxying code.'; @@ -614,6 +615,9 @@ ALTER TABLE ONLY agent_stats ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id); +ALTER TABLE ONLY api_keys + ADD CONSTRAINT api_keys_token_name_key UNIQUE (token_name); + ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); diff --git a/coderd/database/migrations/000100_add_apikey_name.down.sql b/coderd/database/migrations/000100_add_apikey_name.down.sql new file mode 100644 index 0000000000000..480c59da9f59a --- /dev/null +++ b/coderd/database/migrations/000100_add_apikey_name.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE ONLY api_keys + DROP COLUMN IF EXISTS token_name, diff --git a/coderd/database/migrations/000100_add_apikey_name.up.sql b/coderd/database/migrations/000100_add_apikey_name.up.sql new file mode 100644 index 0000000000000..c0325f30b4e8c --- /dev/null +++ b/coderd/database/migrations/000100_add_apikey_name.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE ONLY api_keys + ADD COLUMN IF NOT EXISTS token_name text NOT NULL UNIQUE DEFAULT ''; + diff --git a/coderd/database/models.go b/coderd/database/models.go index 18276551a488e..752b712bed7ac 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1216,6 +1216,7 @@ type APIKey struct { LifetimeSeconds int64 `db:"lifetime_seconds" json:"lifetime_seconds"` IPAddress pqtype.Inet `db:"ip_address" json:"ip_address"` Scope APIKeyScope `db:"scope" json:"scope"` + TokenName string `db:"token_name" json:"token_name"` } type AgentStat struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 354a6ee04c963..ea08e126c3aa3 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -182,7 +182,7 @@ func (q *sqlQuerier) DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID const getAPIKeyByID = `-- name: GetAPIKeyByID :one SELECT - id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope + id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name FROM api_keys WHERE @@ -206,12 +206,13 @@ func (q *sqlQuerier) GetAPIKeyByID(ctx context.Context, id string) (APIKey, erro &i.LifetimeSeconds, &i.IPAddress, &i.Scope, + &i.TokenName, ) return i, err } const getAPIKeysByLoginType = `-- name: GetAPIKeysByLoginType :many -SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope FROM api_keys WHERE login_type = $1 +SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name FROM api_keys WHERE login_type = $1 ` func (q *sqlQuerier) GetAPIKeysByLoginType(ctx context.Context, loginType LoginType) ([]APIKey, error) { @@ -235,6 +236,7 @@ func (q *sqlQuerier) GetAPIKeysByLoginType(ctx context.Context, loginType LoginT &i.LifetimeSeconds, &i.IPAddress, &i.Scope, + &i.TokenName, ); err != nil { return nil, err } @@ -250,7 +252,7 @@ func (q *sqlQuerier) GetAPIKeysByLoginType(ctx context.Context, loginType LoginT } const getAPIKeysByUserID = `-- name: GetAPIKeysByUserID :many -SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope FROM api_keys WHERE login_type = $1 AND user_id = $2 +SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name FROM api_keys WHERE login_type = $1 AND user_id = $2 ` type GetAPIKeysByUserIDParams struct { @@ -279,6 +281,7 @@ func (q *sqlQuerier) GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUse &i.LifetimeSeconds, &i.IPAddress, &i.Scope, + &i.TokenName, ); err != nil { return nil, err } @@ -294,7 +297,7 @@ func (q *sqlQuerier) GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUse } const getAPIKeysLastUsedAfter = `-- name: GetAPIKeysLastUsedAfter :many -SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope FROM api_keys WHERE last_used > $1 +SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name FROM api_keys WHERE last_used > $1 ` func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) { @@ -318,6 +321,7 @@ func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time. &i.LifetimeSeconds, &i.IPAddress, &i.Scope, + &i.TokenName, ); err != nil { return nil, err } @@ -345,7 +349,8 @@ INSERT INTO created_at, updated_at, login_type, - scope + scope, + token_name ) VALUES ($1, @@ -354,7 +359,7 @@ VALUES WHEN 0 THEN 86400 ELSE $2::bigint END - , $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope + , $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name ` type InsertAPIKeyParams struct { @@ -369,6 +374,7 @@ type InsertAPIKeyParams struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` LoginType LoginType `db:"login_type" json:"login_type"` Scope APIKeyScope `db:"scope" json:"scope"` + TokenName string `db:"token_name" json:"token_name"` } func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) { @@ -384,6 +390,7 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) ( arg.UpdatedAt, arg.LoginType, arg.Scope, + arg.TokenName, ) var i APIKey err := row.Scan( @@ -398,6 +405,7 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) ( &i.LifetimeSeconds, &i.IPAddress, &i.Scope, + &i.TokenName, ) return i, err } diff --git a/coderd/database/queries/apikeys.sql b/coderd/database/queries/apikeys.sql index 437f7f5a09bfd..cd7eac909bd6d 100644 --- a/coderd/database/queries/apikeys.sql +++ b/coderd/database/queries/apikeys.sql @@ -30,7 +30,8 @@ INSERT INTO created_at, updated_at, login_type, - scope + scope, + token_name ) VALUES (@id, @@ -39,7 +40,7 @@ VALUES WHEN 0 THEN 86400 ELSE @lifetime_seconds::bigint END - , @hashed_secret, @ip_address, @user_id, @last_used, @expires_at, @created_at, @updated_at, @login_type, @scope) RETURNING *; + , @hashed_secret, @ip_address, @user_id, @last_used, @expires_at, @created_at, @updated_at, @login_type, @scope, @token_name) RETURNING *; -- name: UpdateAPIKeyByID :exec UPDATE diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 6bf2abb04faee..9b7b229490f43 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -6,6 +6,7 @@ type UniqueConstraint string // UniqueConstraint enums. const ( + UniqueApiKeysTokenNameKey UniqueConstraint = "api_keys_token_name_key" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_token_name_key UNIQUE (token_name); UniqueFilesHashCreatedByKey UniqueConstraint = "files_hash_created_by_key" // ALTER TABLE ONLY files ADD CONSTRAINT files_hash_created_by_key UNIQUE (hash, created_by); UniqueGitAuthLinksProviderIDUserIDKey UniqueConstraint = "git_auth_links_provider_id_user_id_key" // ALTER TABLE ONLY git_auth_links ADD CONSTRAINT git_auth_links_provider_id_user_id_key UNIQUE (provider_id, user_id); UniqueGroupMembersUserIDGroupIDKey UniqueConstraint = "group_members_user_id_group_id_key" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_group_id_key UNIQUE (user_id, group_id); diff --git a/coderd/users.go b/coderd/users.go index 10cb90d6a1a4b..82b1a8414628a 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1231,5 +1231,6 @@ func convertAPIKey(k database.APIKey) codersdk.APIKey { LoginType: codersdk.LoginType(k.LoginType), Scope: codersdk.APIKeyScope(k.Scope), LifetimeSeconds: k.LifetimeSeconds, + TokenName: k.TokenName, } } diff --git a/codersdk/apikey.go b/codersdk/apikey.go index c7e6bce133706..a651fe971054a 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -20,6 +20,7 @@ type APIKey struct { UpdatedAt time.Time `json:"updated_at" validate:"required" format:"date-time"` LoginType LoginType `json:"login_type" validate:"required" enums:"password,github,oidc,token"` Scope APIKeyScope `json:"scope" validate:"required" enums:"all,application_connect"` + TokenName string `json:"token_name" validate:"required"` LifetimeSeconds int64 `json:"lifetime_seconds" validate:"required"` } @@ -44,8 +45,9 @@ const ( ) type CreateTokenRequest struct { - Lifetime time.Duration `json:"lifetime"` - Scope APIKeyScope `json:"scope" enums:"all,application_connect"` + Lifetime time.Duration `json:"lifetime"` + Scope APIKeyScope `json:"scope" enums:"all,application_connect"` + TokenName string `json:"token_name"` } // GenerateAPIKeyResponse contains an API key for a user. diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 762b2f3762ca7..3889ded8d7c5c 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -11,7 +11,7 @@ We track the following resources: | Resource | | | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| APIKey
write |
FieldTracked
created_atfalse
expires_atfalse
hashed_secretfalse
idfalse
ip_addressfalse
last_usedfalse
lifetime_secondsfalse
login_typefalse
scopefalse
updated_atfalse
user_idfalse
| +| APIKey
write |
FieldTracked
created_atfalse
expires_atfalse
hashed_secretfalse
idfalse
ip_addressfalse
last_usedfalse
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idfalse
| | Group
create, write, delete |
FieldTracked
avatar_urltrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| | GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| | License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 948a0e6304197..fdba31c1aceb0 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -358,6 +358,7 @@ "lifetime_seconds": 0, "login_type": "password", "scope": "all", + "token_name": "string", "updated_at": "2019-08-24T14:15:22Z", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" } @@ -374,6 +375,7 @@ | `lifetime_seconds` | integer | true | | | | `login_type` | [codersdk.LoginType](#codersdklogintype) | true | | | | `scope` | [codersdk.APIKeyScope](#codersdkapikeyscope) | true | | | +| `token_name` | string | true | | | | `updated_at` | string | true | | | | `user_id` | string | true | | | @@ -1068,16 +1070,18 @@ CreateParameterRequest is a structure used to create a new parameter value for a ```json { "lifetime": 0, - "scope": "all" + "scope": "all", + "token_name": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ---------- | -------------------------------------------- | -------- | ------------ | ----------- | -| `lifetime` | integer | false | | | -| `scope` | [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | | +| Name | Type | Required | Restrictions | Description | +| ------------ | -------------------------------------------- | -------- | ------------ | ----------- | +| `lifetime` | integer | false | | | +| `scope` | [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | | +| `token_name` | string | false | | | #### Enumerated Values diff --git a/docs/api/users.md b/docs/api/users.md index 1fd8e73fc32e9..40ae77ed380fb 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -577,6 +577,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/tokens \ "lifetime_seconds": 0, "login_type": "password", "scope": "all", + "token_name": "string", "updated_at": "2019-08-24T14:15:22Z", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" } @@ -603,6 +604,7 @@ Status Code **200** | `» lifetime_seconds` | integer | true | | | | `» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | true | | | | `» scope` | [codersdk.APIKeyScope](schemas.md#codersdkapikeyscope) | true | | | +| `» token_name` | string | true | | | | `» updated_at` | string(date-time) | true | | | | `» user_id` | string(uuid) | true | | | @@ -638,7 +640,8 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/keys/tokens \ ```json { "lifetime": 0, - "scope": "all" + "scope": "all", + "token_name": "string" } ``` @@ -700,6 +703,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/{keyid} \ "lifetime_seconds": 0, "login_type": "password", "scope": "all", + "token_name": "string", "updated_at": "2019-08-24T14:15:22Z", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" } diff --git a/docs/cli/coder_tokens.md b/docs/cli/coder_tokens.md index 89877e555b72c..1130422bada75 100644 --- a/docs/cli/coder_tokens.md +++ b/docs/cli/coder_tokens.md @@ -28,8 +28,8 @@ coder tokens [flags] ## Subcommands -| Name | Purpose | -| -------------------------------------------- | --------------- | -| [create](./coder_tokens_create) | Create a tokens | -| [list](./coder_tokens_list) | List tokens | -| [remove](./coder_tokens_remove) | Delete a token | +| Name | Purpose | +| -------------------------------------------- | -------------- | +| [create](./coder_tokens_create) | Create a token | +| [list](./coder_tokens_list) | List tokens | +| [remove](./coder_tokens_remove) | Delete a token | diff --git a/docs/cli/coder_tokens_create.md b/docs/cli/coder_tokens_create.md index 5890243607b0b..6d5b92a5e5c5f 100644 --- a/docs/cli/coder_tokens_create.md +++ b/docs/cli/coder_tokens_create.md @@ -2,7 +2,7 @@ # coder tokens create -Create a tokens +Create a token ## Usage @@ -20,3 +20,10 @@ Specify a duration for the lifetime of the token. | --- | --- | | Consumes | $CODER_TOKEN_LIFETIME | | Default | 720h0m0s | + +### --name, -n + +Specify a human-readable name. +
+| | | +| --- | --- | diff --git a/docs/cli/coder_tokens_list.md b/docs/cli/coder_tokens_list.md index f629f4825436f..b36d4ea291bcb 100644 --- a/docs/cli/coder_tokens_list.md +++ b/docs/cli/coder_tokens_list.md @@ -22,11 +22,11 @@ Specifies whether all users' tokens will be listed or not (must have Owner role ### --column, -c -Columns to display in table output. Available columns: id, last used, expires at, created at, owner +Columns to display in table output. Available columns: id, name, last used, expires at, created at, owner
| | | | --- | --- | -| Default | [id,last used,expires at,created at] | +| Default | [id,name,last used,expires at,created at] | ### --output, -o diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 239aecfd3017e..c94f70174e68e 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -145,6 +145,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{ "lifetime_seconds": ActionIgnore, "ip_address": ActionIgnore, "scope": ActionIgnore, + "token_name": ActionIgnore, }, // TODO: track an ID here when the below ticket is completed: // https://github.com/coder/coder/pull/6012 diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 7849875f19b53..b86a8a6cce7dc 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -10,6 +10,7 @@ export interface APIKey { readonly updated_at: string readonly login_type: LoginType readonly scope: APIKeyScope + readonly token_name: string readonly lifetime_seconds: number } @@ -223,6 +224,7 @@ export interface CreateTokenRequest { // This is likely an enum in an external package ("time.Duration") readonly lifetime: number readonly scope: APIKeyScope + readonly token_name: string } // From codersdk/users.go From 20fc87c9e1ed8e190ac2e3b3c27928f796e4a6c1 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Mon, 27 Feb 2023 21:28:08 +0000 Subject: [PATCH 10/23] fixed auth --- coderd/userauth.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coderd/userauth.go b/coderd/userauth.go index 1a1ad6ad53b2e..aeb5bd4c64598 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -132,6 +132,7 @@ func (api *API) postLogin(rw http.ResponseWriter, r *http.Request) { UserID: user.ID, LoginType: database.LoginTypePassword, RemoteAddr: r.RemoteAddr, + TokenName: namesgenerator.GetRandomName(1), }) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -920,6 +921,7 @@ func (api *API) oauthLogin(r *http.Request, params oauthLoginParams) (*http.Cook UserID: user.ID, LoginType: params.LoginType, RemoteAddr: r.RemoteAddr, + TokenName: namesgenerator.GetRandomName(1), }) if err != nil { return nil, database.APIKey{}, xerrors.Errorf("create API key: %w", err) From f399fb8322b370cae239e68ea1d1caa7717d4263 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Mon, 27 Feb 2023 21:45:35 +0000 Subject: [PATCH 11/23] lint, prettier, tests --- cli/testdata/coder_tokens_--help.golden | 2 +- cli/testdata/coder_tokens_create_--help.golden | 3 ++- cli/testdata/coder_tokens_list_--help.golden | 6 +++--- coderd/apikey.go | 1 + coderd/provisionerdserver/provisionerdserver.go | 6 +++--- site/src/testHelpers/entities.ts | 2 ++ 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/cli/testdata/coder_tokens_--help.golden b/cli/testdata/coder_tokens_--help.golden index 66b068ebc180b..06e8ed16c9ff1 100644 --- a/cli/testdata/coder_tokens_--help.golden +++ b/cli/testdata/coder_tokens_--help.golden @@ -22,7 +22,7 @@ Get Started: $ coder tokens rm WuoWs4ZsMX Commands: - create Create a tokens + create Create a token list List tokens remove Delete a token diff --git a/cli/testdata/coder_tokens_create_--help.golden b/cli/testdata/coder_tokens_create_--help.golden index 22cac73e41b95..dfeec67f0458a 100644 --- a/cli/testdata/coder_tokens_create_--help.golden +++ b/cli/testdata/coder_tokens_create_--help.golden @@ -1,4 +1,4 @@ -Create a tokens +Create a token Usage: coder tokens create [flags] @@ -7,6 +7,7 @@ Flags: -h, --help help for create --lifetime duration Specify a duration for the lifetime of the token. Consumes $CODER_TOKEN_LIFETIME (default 720h0m0s) + -n, --name string Specify a human-readable name. Global Flags: --global-config coder Path to the global coder config directory. diff --git a/cli/testdata/coder_tokens_list_--help.golden b/cli/testdata/coder_tokens_list_--help.golden index 15711ddcccea0..6b7d93425206b 100644 --- a/cli/testdata/coder_tokens_list_--help.golden +++ b/cli/testdata/coder_tokens_list_--help.golden @@ -9,9 +9,9 @@ Aliases: Flags: -a, --all Specifies whether all users' tokens will be listed or not (must have Owner role to see all tokens). - -c, --column strings Columns to display in table output. Available columns: id, last used, - expires at, created at, owner (default [id,last used,expires - at,created at]) + -c, --column strings Columns to display in table output. Available columns: id, name, last + used, expires at, created at, owner (default [id,name,last + used,expires at,created at]) -h, --help help for list -o, --output string Output format. Available formats: table, json (default "table") diff --git a/coderd/apikey.go b/coderd/apikey.go index 53b46dd08c62b..bc7d7b13b18a1 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -96,6 +96,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { Detail: "This value is already in use and should be unique.", }}, }) + return } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to create API key.", diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index c1f7045921baa..fd091d8abe64f 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -307,7 +307,7 @@ func (server *Server) includeLastVariableValues(ctx context.Context, templateVer templateVersion, err := server.Database.GetTemplateVersionByID(ctx, templateVersionID) if err != nil { - return nil, fmt.Errorf("get template version: %w", err) + return nil, xerrors.New("get template version: %w", err) } if templateVersion.TemplateID.UUID == uuid.Nil { @@ -316,7 +316,7 @@ func (server *Server) includeLastVariableValues(ctx context.Context, templateVer template, err := server.Database.GetTemplateByID(ctx, templateVersion.TemplateID.UUID) if err != nil { - return nil, fmt.Errorf("get template: %w", err) + return nil, xerrors.New("get template: %w", err) } if template.ActiveVersionID == uuid.Nil { @@ -325,7 +325,7 @@ func (server *Server) includeLastVariableValues(ctx context.Context, templateVer templateVariables, err := server.Database.GetTemplateVersionVariables(ctx, template.ActiveVersionID) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("get template version variables: %w", err) + return nil, xerrors.New("get template version variables: %w", err) } for _, templateVariable := range templateVariables { diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 8876bd169a7a2..7d5c2aa582f01 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -41,6 +41,7 @@ export const MockTokens: TypesGen.APIKey[] = [ login_type: "token", scope: "all", lifetime_seconds: 2592000, + token_name: "token-one", }, { id: "tBoVE3dqLl", @@ -52,6 +53,7 @@ export const MockTokens: TypesGen.APIKey[] = [ login_type: "token", scope: "all", lifetime_seconds: 2592000, + token_name: "token-two", }, ] From 5963708072fcbb33b33ad34cf8bb32f9c31f0545 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Tue, 28 Feb 2023 20:57:03 +0000 Subject: [PATCH 12/23] added unique index for login type token --- coderd/database/dbauthz/querier.go | 4 ++ coderd/database/dbauthz/querier_test.go | 10 +++++ coderd/database/dbfake/databasefake.go | 15 +++++++ coderd/database/dbgen/generator.go | 1 + coderd/database/dump.sql | 5 +-- .../migrations/000100_add_apikey_name.up.sql | 3 -- ...wn.sql => 000101_add_apikey_name.down.sql} | 0 .../migrations/000101_add_apikey_name.up.sql | 17 ++++++++ coderd/database/querier.go | 2 + coderd/database/queries.sql.go | 39 +++++++++++++++++++ coderd/database/queries/apikeys.sql | 13 +++++++ coderd/database/unique_constraint.go | 2 +- codersdk/apikey.go | 6 +-- 13 files changed, 107 insertions(+), 10 deletions(-) delete mode 100644 coderd/database/migrations/000100_add_apikey_name.up.sql rename coderd/database/migrations/{000100_add_apikey_name.down.sql => 000101_add_apikey_name.down.sql} (100%) create mode 100644 coderd/database/migrations/000101_add_apikey_name.up.sql diff --git a/coderd/database/dbauthz/querier.go b/coderd/database/dbauthz/querier.go index 691f680e42feb..cc912587741ff 100644 --- a/coderd/database/dbauthz/querier.go +++ b/coderd/database/dbauthz/querier.go @@ -36,6 +36,10 @@ func (q *querier) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey return fetch(q.log, q.auth, q.db.GetAPIKeyByID)(ctx, id) } +func (q *querier) GetAPIKeyByName(ctx context.Context, arg database.GetAPIKeyByNameParams) (database.APIKey, error) { + return fetch(q.log, q.auth, q.db.GetAPIKeyByName)(ctx, arg) +} + func (q *querier) GetAPIKeysByLoginType(ctx context.Context, loginType database.LoginType) ([]database.APIKey, error) { return fetchWithPostFilter(q.auth, q.db.GetAPIKeysByLoginType)(ctx, loginType) } diff --git a/coderd/database/dbauthz/querier_test.go b/coderd/database/dbauthz/querier_test.go index 0f7e7c4ffa45d..5dc41679add4b 100644 --- a/coderd/database/dbauthz/querier_test.go +++ b/coderd/database/dbauthz/querier_test.go @@ -23,6 +23,16 @@ func (s *MethodTestSuite) TestAPIKey() { key, _ := dbgen.APIKey(s.T(), db, database.APIKey{}) check.Args(key.ID).Asserts(key, rbac.ActionRead).Returns(key) })) + s.Run("GetAPIKeyByName", s.Subtest(func(db database.Store, check *expects) { + key, _ := dbgen.APIKey(s.T(), db, database.APIKey{ + TokenName: "marge-cat", + LoginType: database.LoginTypeToken, + }) + check.Args(database.GetAPIKeyByNameParams{ + TokenName: key.TokenName, + UserID: key.UserID, + }).Asserts(key, rbac.ActionRead).Returns(key) + })) s.Run("GetAPIKeysByLoginType", s.Subtest(func(db database.Store, check *expects) { a, _ := dbgen.APIKey(s.T(), db, database.APIKey{LoginType: database.LoginTypePassword}) b, _ := dbgen.APIKey(s.T(), db, database.APIKey{LoginType: database.LoginTypePassword}) diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index 7c1ea4b3f4abd..3320ce1f6c80d 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -454,6 +454,21 @@ func (q *fakeQuerier) GetAPIKeyByID(_ context.Context, id string) (database.APIK return database.APIKey{}, sql.ErrNoRows } +func (q *fakeQuerier) GetAPIKeyByName(_ context.Context, params database.GetAPIKeyByNameParams) (database.APIKey, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + if params.TokenName == "" { + return database.APIKey{}, sql.ErrNoRows + } + for _, apiKey := range q.apiKeys { + if params.UserID == apiKey.UserID && params.TokenName == apiKey.TokenName { + return apiKey, nil + } + } + return database.APIKey{}, sql.ErrNoRows +} + func (q *fakeQuerier) GetAPIKeysLastUsedAfter(_ context.Context, after time.Time) ([]database.APIKey, error) { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/database/dbgen/generator.go b/coderd/database/dbgen/generator.go index cb8f52c06529a..f272f30ec7a6c 100644 --- a/coderd/database/dbgen/generator.go +++ b/coderd/database/dbgen/generator.go @@ -90,6 +90,7 @@ func APIKey(t testing.TB, db database.Store, seed database.APIKey) (key database UpdatedAt: takeFirst(seed.UpdatedAt, database.Now()), LoginType: takeFirst(seed.LoginType, database.LoginTypePassword), Scope: takeFirst(seed.Scope, database.APIKeyScopeAll), + TokenName: takeFirst(seed.TokenName), }) require.NoError(t, err, "insert api key") return key, fmt.Sprintf("%s-%s", key.ID, secret) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 9ca1f9b60c34d..5ac2bbe70ec67 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -618,9 +618,6 @@ ALTER TABLE ONLY agent_stats ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id); -ALTER TABLE ONLY api_keys - ADD CONSTRAINT api_keys_token_name_key UNIQUE (token_name); - ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); @@ -742,6 +739,8 @@ CREATE INDEX idx_agent_stats_created_at ON agent_stats USING btree (created_at); CREATE INDEX idx_agent_stats_user_id ON agent_stats USING btree (user_id); +CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type); + CREATE INDEX idx_api_keys_user ON api_keys USING btree (user_id); CREATE INDEX idx_audit_log_organization_id ON audit_logs USING btree (organization_id); diff --git a/coderd/database/migrations/000100_add_apikey_name.up.sql b/coderd/database/migrations/000100_add_apikey_name.up.sql deleted file mode 100644 index c0325f30b4e8c..0000000000000 --- a/coderd/database/migrations/000100_add_apikey_name.up.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE ONLY api_keys - ADD COLUMN IF NOT EXISTS token_name text NOT NULL UNIQUE DEFAULT ''; - diff --git a/coderd/database/migrations/000100_add_apikey_name.down.sql b/coderd/database/migrations/000101_add_apikey_name.down.sql similarity index 100% rename from coderd/database/migrations/000100_add_apikey_name.down.sql rename to coderd/database/migrations/000101_add_apikey_name.down.sql diff --git a/coderd/database/migrations/000101_add_apikey_name.up.sql b/coderd/database/migrations/000101_add_apikey_name.up.sql new file mode 100644 index 0000000000000..f1ba24ae0935b --- /dev/null +++ b/coderd/database/migrations/000101_add_apikey_name.up.sql @@ -0,0 +1,17 @@ +BEGIN; + +ALTER TABLE ONLY api_keys + ADD COLUMN IF NOT EXISTS token_name text NOT NULL DEFAULT ''; + +UPDATE + api_keys +SET + token_name = gen_random_uuid ()::text +WHERE + login_type = 'token'; + +CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) +WHERE + (login_type = 'token'); + +COMMIT; diff --git a/coderd/database/querier.go b/coderd/database/querier.go index a2849211b60be..a3885482eef77 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -30,6 +30,8 @@ type sqlcQuerier interface { DeleteParameterValueByID(ctx context.Context, id uuid.UUID) error DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) + // there is no unique constraint on empty token names + GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error) GetAPIKeysByLoginType(ctx context.Context, loginType LoginType) ([]APIKey, error) GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUserIDParams) ([]APIKey, error) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b71b5a5b78104..7bcc82458cfd4 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -211,6 +211,45 @@ func (q *sqlQuerier) GetAPIKeyByID(ctx context.Context, id string) (APIKey, erro return i, err } +const getAPIKeyByName = `-- name: GetAPIKeyByName :one +SELECT + id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name +FROM + api_keys +WHERE + user_id = $1 AND + token_name = $2 AND + token_name != '' +LIMIT + 1 +` + +type GetAPIKeyByNameParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + TokenName string `db:"token_name" json:"token_name"` +} + +// there is no unique constraint on empty token names +func (q *sqlQuerier) GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error) { + row := q.db.QueryRowContext(ctx, getAPIKeyByName, arg.UserID, arg.TokenName) + var i APIKey + err := row.Scan( + &i.ID, + &i.HashedSecret, + &i.UserID, + &i.LastUsed, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.LoginType, + &i.LifetimeSeconds, + &i.IPAddress, + &i.Scope, + &i.TokenName, + ) + return i, err +} + const getAPIKeysByLoginType = `-- name: GetAPIKeysByLoginType :many SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name FROM api_keys WHERE login_type = $1 ` diff --git a/coderd/database/queries/apikeys.sql b/coderd/database/queries/apikeys.sql index cd7eac909bd6d..53caa7633521f 100644 --- a/coderd/database/queries/apikeys.sql +++ b/coderd/database/queries/apikeys.sql @@ -8,6 +8,19 @@ WHERE LIMIT 1; +-- name: GetAPIKeyByName :one +SELECT + * +FROM + api_keys +WHERE + user_id = @user_id AND + token_name = @token_name AND +-- there is no unique constraint on empty token names + token_name != '' +LIMIT + 1; + -- name: GetAPIKeysLastUsedAfter :many SELECT * FROM api_keys WHERE last_used > $1; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 9b7b229490f43..f09f6ebdae374 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -6,7 +6,6 @@ type UniqueConstraint string // UniqueConstraint enums. const ( - UniqueApiKeysTokenNameKey UniqueConstraint = "api_keys_token_name_key" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_token_name_key UNIQUE (token_name); UniqueFilesHashCreatedByKey UniqueConstraint = "files_hash_created_by_key" // ALTER TABLE ONLY files ADD CONSTRAINT files_hash_created_by_key UNIQUE (hash, created_by); UniqueGitAuthLinksProviderIDUserIDKey UniqueConstraint = "git_auth_links_provider_id_user_id_key" // ALTER TABLE ONLY git_auth_links ADD CONSTRAINT git_auth_links_provider_id_user_id_key UNIQUE (provider_id, user_id); UniqueGroupMembersUserIDGroupIDKey UniqueConstraint = "group_members_user_id_group_id_key" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_group_id_key UNIQUE (user_id, group_id); @@ -24,6 +23,7 @@ const ( UniqueWorkspaceBuildsJobIDKey UniqueConstraint = "workspace_builds_job_id_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_key UNIQUE (job_id); UniqueWorkspaceBuildsWorkspaceIDBuildNumberKey UniqueConstraint = "workspace_builds_workspace_id_build_number_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_workspace_id_build_number_key UNIQUE (workspace_id, build_number); UniqueWorkspaceResourceMetadataName UniqueConstraint = "workspace_resource_metadata_name" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_name UNIQUE (workspace_resource_id, key); + UniqueIndexApiKeyName UniqueConstraint = "idx_api_key_name" // CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type); UniqueIndexOrganizationName UniqueConstraint = "idx_organization_name" // CREATE UNIQUE INDEX idx_organization_name ON organizations USING btree (name); UniqueIndexOrganizationNameLower UniqueConstraint = "idx_organization_name_lower" // CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name)); UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false); diff --git a/codersdk/apikey.go b/codersdk/apikey.go index a651fe971054a..609e2907c264d 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -135,9 +135,9 @@ func (c *Client) APIKey(ctx context.Context, userID string, id string) (*APIKey, return apiKey, json.NewDecoder(res.Body).Decode(apiKey) } -// DeleteAPIKey deletes API key by id. -func (c *Client) DeleteAPIKey(ctx context.Context, userID string, id string) error { - res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/users/%s/keys/%s", userID, id), nil) +// DeleteAPIKey deletes API key by name. +func (c *Client) DeleteAPIKey(ctx context.Context, userID string, name string) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/users/%s/keys/%s", userID, name), nil) if err != nil { return err } From 187f8126e11752a2c2ad67f6c6044b7f0b790c94 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Tue, 28 Feb 2023 22:25:26 +0000 Subject: [PATCH 13/23] remove tokens by name --- cli/tokens.go | 9 ++++-- coderd/apidoc/docs.go | 46 +++++++++++++++++++++++++++++-- coderd/apidoc/swagger.json | 42 ++++++++++++++++++++++++++-- coderd/apikey.go | 46 +++++++++++++++++++++++++++++-- coderd/coderd.go | 5 +++- coderd/coderdtest/authorize.go | 4 +++ coderd/users_test.go | 14 +++++----- coderd/workspaceapps_test.go | 4 +-- codersdk/apikey.go | 24 ++++++++++++---- docs/api/users.md | 49 ++++++++++++++++++++++++++++++++- docs/cli/coder_tokens_remove.md | 2 +- 11 files changed, 219 insertions(+), 26 deletions(-) diff --git a/cli/tokens.go b/cli/tokens.go index ca877c08dc7a3..0878d6d502649 100644 --- a/cli/tokens.go +++ b/cli/tokens.go @@ -179,7 +179,7 @@ func listTokens() *cobra.Command { func removeToken() *cobra.Command { cmd := &cobra.Command{ - Use: "remove [id]", + Use: "remove [name]", Aliases: []string{"rm"}, Short: "Delete a token", Args: cobra.ExactArgs(1), @@ -189,7 +189,12 @@ func removeToken() *cobra.Command { return xerrors.Errorf("create codersdk client: %w", err) } - err = client.DeleteAPIKey(cmd.Context(), codersdk.Me, args[0]) + token, err := client.APIKeyByName(cmd.Context(), codersdk.Me, args[0]) + if err != nil { + return xerrors.Errorf("delete api key: %w", err) + } + + err = client.DeleteAPIKey(cmd.Context(), codersdk.Me, token.ID) if err != nil { return xerrors.Errorf("delete api key: %w", err) } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 324d2fa15ca28..3f15783069c28 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3350,8 +3350,8 @@ const docTemplate = `{ "tags": [ "Users" ], - "summary": "Get API key", - "operationId": "get-api-key", + "summary": "Get API key by ID", + "operationId": "get-api-key-by-id", "parameters": [ { "type": "string", @@ -3413,6 +3413,48 @@ const docTemplate = `{ } } }, + "/users/{user}/keys/{keyname}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get API key by token name", + "operationId": "get-api-key-by-name", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "string", + "description": "Key Name", + "name": "keyname", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.APIKey" + } + } + } + } + }, "/users/{user}/organizations": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 8e87c394a8556..e18f35477bf1c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2940,8 +2940,8 @@ ], "produces": ["application/json"], "tags": ["Users"], - "summary": "Get API key", - "operationId": "get-api-key", + "summary": "Get API key by ID", + "operationId": "get-api-key-by-id", "parameters": [ { "type": "string", @@ -3001,6 +3001,44 @@ } } }, + "/users/{user}/keys/{keyname}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Users"], + "summary": "Get API key by token name", + "operationId": "get-api-key-by-name", + "parameters": [ + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "string", + "description": "Key Name", + "name": "keyname", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.APIKey" + } + } + } + } + }, "/users/{user}/organizations": { "get": { "security": [ diff --git a/coderd/apikey.go b/coderd/apikey.go index bc7d7b13b18a1..97bec980520ca 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -152,8 +152,8 @@ func (api *API) postAPIKey(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusCreated, codersdk.GenerateAPIKeyResponse{Key: cookie.Value}) } -// @Summary Get API key -// @ID get-api-key +// @Summary Get API key by ID +// @ID get-api-key-by-id // @Security CoderSessionToken // @Produce json // @Tags Users @@ -161,7 +161,7 @@ func (api *API) postAPIKey(rw http.ResponseWriter, r *http.Request) { // @Param keyid path string true "Key ID" format(uuid) // @Success 200 {object} codersdk.APIKey // @Router /users/{user}/keys/{keyid} [get] -func (api *API) apiKey(rw http.ResponseWriter, r *http.Request) { +func (api *API) apiKeyByID(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() keyID := chi.URLParam(r, "keyid") @@ -186,6 +186,46 @@ func (api *API) apiKey(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, convertAPIKey(key)) } +// @Summary Get API key by token name +// @ID get-api-key-by-name +// @Security CoderSessionToken +// @Produce json +// @Tags Users +// @Param user path string true "User ID, name, or me" +// @Param keyname path string true "Key Name" format(string) +// @Success 200 {object} codersdk.APIKey +// @Router /users/{user}/keys/tokens/{keyname} [get] +func (api *API) apiKeyByName(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + user = httpmw.UserParam(r) + tokenName = chi.URLParam(r, "keyname") + ) + + token, err := api.Database.GetAPIKeyByName(ctx, database.GetAPIKeyByNameParams{ + TokenName: tokenName, + UserID: user.ID, + }) + if errors.Is(err, sql.ErrNoRows) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching API key.", + Detail: err.Error(), + }) + return + } + + if !api.Authorize(r, rbac.ActionRead, token) { + httpapi.ResourceNotFound(rw) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, convertAPIKey(token)) +} + // @Summary Get user tokens // @ID get-user-tokens // @Security CoderSessionToken diff --git a/coderd/coderd.go b/coderd/coderd.go index 79306dcd03481..32e46765d97ea 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -556,9 +556,12 @@ func New(options *Options) *API { r.Route("/tokens", func(r chi.Router) { r.Post("/", api.postToken) r.Get("/", api.tokens) + r.Route("/{keyname}", func(r chi.Router) { + r.Get("/", api.apiKeyByName) + }) }) r.Route("/{keyid}", func(r chi.Router) { - r.Get("/", api.apiKey) + r.Get("/", api.apiKeyByID) r.Delete("/", api.deleteAPIKey) }) }) diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index c4d4fd8a541f0..ceba9d6879a5e 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -95,6 +95,10 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) { AssertObject: rbac.ResourceAPIKey, AssertAction: rbac.ActionRead, }, + "GET:/api/v2/users/{user}/keys/tokens/{keyname}": { + AssertObject: rbac.ResourceAPIKey, + AssertAction: rbac.ActionRead, + }, "GET:/api/v2/workspacebuilds/{workspacebuild}": { AssertAction: rbac.ActionRead, AssertObject: workspaceRBACObj, diff --git a/coderd/users_test.go b/coderd/users_test.go index 801aa277f1ca3..a5916be338e7d 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -266,7 +266,7 @@ func TestPostLogin(t *testing.T) { defer cancel() split := strings.Split(client.SessionToken(), "-") - key, err := client.APIKey(ctx, admin.UserID.String(), split[0]) + key, err := client.APIKeyByID(ctx, admin.UserID.String(), split[0]) require.NoError(t, err, "fetch login key") require.Equal(t, int64(86400), key.LifetimeSeconds, "default should be 86400") @@ -274,7 +274,7 @@ func TestPostLogin(t *testing.T) { token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{}) require.NoError(t, err, "make new token api key") split = strings.Split(token.Key, "-") - apiKey, err := client.APIKey(ctx, admin.UserID.String(), split[0]) + apiKey, err := client.APIKeyByID(ctx, admin.UserID.String(), split[0]) require.NoError(t, err, "fetch api key") require.True(t, apiKey.ExpiresAt.After(time.Now().Add(time.Hour*24*29)), "default tokens lasts more than 29 days") @@ -356,7 +356,7 @@ func TestPostLogout(t *testing.T) { defer cancel() keyID := strings.Split(client.SessionToken(), "-")[0] - apiKey, err := client.APIKey(ctx, admin.UserID.String(), keyID) + apiKey, err := client.APIKeyByID(ctx, admin.UserID.String(), keyID) require.NoError(t, err) require.Equal(t, keyID, apiKey.ID, "API key should exist in the database") @@ -385,7 +385,7 @@ func TestPostLogout(t *testing.T) { } require.True(t, found, "auth cookie should be returned") - _, err = client.APIKey(ctx, admin.UserID.String(), keyID) + _, err = client.APIKeyByID(ctx, admin.UserID.String(), keyID) sdkErr := &codersdk.Error{} require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode(), "Expecting 401") @@ -723,7 +723,7 @@ func TestUpdateUserPassword(t *testing.T) { // Trying to get an API key should fail since our client's token // has been deleted. - _, err = client.APIKey(ctx, user.UserID.String(), apikey1.Key) + _, err = client.APIKeyByID(ctx, user.UserID.String(), apikey1.Key) require.Error(t, err) cerr := coderdtest.SDKError(t, err) require.Equal(t, http.StatusUnauthorized, cerr.StatusCode()) @@ -738,12 +738,12 @@ func TestUpdateUserPassword(t *testing.T) { // Trying to get an API key should fail since all keys are deleted // on password change. - _, err = client.APIKey(ctx, user.UserID.String(), apikey1.Key) + _, err = client.APIKeyByID(ctx, user.UserID.String(), apikey1.Key) require.Error(t, err) cerr = coderdtest.SDKError(t, err) require.Equal(t, http.StatusNotFound, cerr.StatusCode()) - _, err = client.APIKey(ctx, user.UserID.String(), apikey2.Key) + _, err = client.APIKeyByID(ctx, user.UserID.String(), apikey2.Key) require.Error(t, err) cerr = coderdtest.SDKError(t, err) require.Equal(t, http.StatusNotFound, cerr.StatusCode()) diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index 40f08424b881d..6c1b24293dcf0 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -438,7 +438,7 @@ func TestWorkspaceApplicationAuth(t *testing.T) { // Get the current user and API key. user, err := client.User(ctx, codersdk.Me) require.NoError(t, err) - currentAPIKey, err := client.APIKey(ctx, firstUser.UserID.String(), strings.Split(client.SessionToken(), "-")[0]) + currentAPIKey, err := client.APIKeyByID(ctx, firstUser.UserID.String(), strings.Split(client.SessionToken(), "-")[0]) require.NoError(t, err) // Try to load the application without authentication. @@ -500,7 +500,7 @@ func TestWorkspaceApplicationAuth(t *testing.T) { apiKey := cookies[0].Value // Fetch the API key. - apiKeyInfo, err := client.APIKey(ctx, firstUser.UserID.String(), strings.Split(apiKey, "-")[0]) + apiKeyInfo, err := client.APIKeyByID(ctx, firstUser.UserID.String(), strings.Split(apiKey, "-")[0]) require.NoError(t, err) require.Equal(t, user.ID, apiKeyInfo.UserID) require.Equal(t, codersdk.LoginTypePassword, apiKeyInfo.LoginType) diff --git a/codersdk/apikey.go b/codersdk/apikey.go index 609e2907c264d..238e34b8e9447 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -121,8 +121,8 @@ func (c *Client) Tokens(ctx context.Context, userID string, filter TokensFilter) return apiKey, json.NewDecoder(res.Body).Decode(&apiKey) } -// APIKey returns the api key by id. -func (c *Client) APIKey(ctx context.Context, userID string, id string) (*APIKey, error) { +// APIKeyByID returns the api key by id. +func (c *Client) APIKeyByID(ctx context.Context, userID string, id string) (*APIKey, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/keys/%s", userID, id), nil) if err != nil { return nil, err @@ -135,9 +135,23 @@ func (c *Client) APIKey(ctx context.Context, userID string, id string) (*APIKey, return apiKey, json.NewDecoder(res.Body).Decode(apiKey) } -// DeleteAPIKey deletes API key by name. -func (c *Client) DeleteAPIKey(ctx context.Context, userID string, name string) error { - res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/users/%s/keys/%s", userID, name), nil) +// APIKeyByName returns the api key by name. +func (c *Client) APIKeyByName(ctx context.Context, userID string, name string) (*APIKey, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/keys/tokens/%s", userID, name), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode > http.StatusCreated { + return nil, ReadBodyAsError(res) + } + apiKey := &APIKey{} + return apiKey, json.NewDecoder(res.Body).Decode(apiKey) +} + +// DeleteAPIKey deletes API key by id. +func (c *Client) DeleteAPIKey(ctx context.Context, userID string, id string) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/users/%s/keys/%s", userID, id), nil) if err != nil { return err } diff --git a/docs/api/users.md b/docs/api/users.md index 40ae77ed380fb..d820d0e52291e 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -670,7 +670,7 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/keys/tokens \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Get API key +## Get API key by ID ### Code samples @@ -744,6 +744,53 @@ curl -X DELETE http://coder-server:8080/api/v2/users/{user}/keys/{keyid} \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get API key by token name + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/{keyname} \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /users/{user}/keys/{keyname}` + +### Parameters + +| Name | In | Type | Required | Description | +| --------- | ---- | -------------- | -------- | -------------------- | +| `user` | path | string | true | User ID, name, or me | +| `keyname` | path | string(string) | true | Key Name | + +### Example responses + +> 200 Response + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "expires_at": "2019-08-24T14:15:22Z", + "id": "string", + "last_used": "2019-08-24T14:15:22Z", + "lifetime_seconds": 0, + "login_type": "password", + "scope": "all", + "token_name": "string", + "updated_at": "2019-08-24T14:15:22Z", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.APIKey](schemas.md#codersdkapikey) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get organizations by user ### Code samples diff --git a/docs/cli/coder_tokens_remove.md b/docs/cli/coder_tokens_remove.md index b6a445d2c68b7..f7041c37851e1 100644 --- a/docs/cli/coder_tokens_remove.md +++ b/docs/cli/coder_tokens_remove.md @@ -7,5 +7,5 @@ Delete a token ## Usage ```console -coder tokens remove [id] [flags] +coder tokens remove [name] [flags] ``` From 360905715f66ee24c1390ba8116ddcaab0743652 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Tue, 28 Feb 2023 22:37:29 +0000 Subject: [PATCH 14/23] better check for unique constraint --- coderd/apikey.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/coderd/apikey.go b/coderd/apikey.go index 97bec980520ca..dc5176dd05b97 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -9,7 +9,6 @@ import ( "net" "net/http" "strconv" - "strings" "time" "github.com/go-chi/chi/v5" @@ -88,7 +87,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { TokenName: tokenName, }) if err != nil { - if strings.Contains(err.Error(), "duplicate key value violates unique constraint") { + if database.IsUniqueViolation(err) { httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ Message: fmt.Sprintf("A token with name %q already exists.", tokenName), Validations: []codersdk.ValidationError{{ From b824bcafcdc91f5762a1c9182309a98b8669859b Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Wed, 1 Mar 2023 17:08:32 +0000 Subject: [PATCH 15/23] docs --- coderd/apidoc/docs.go | 58 ++++++++++++++--------------- coderd/apidoc/swagger.json | 54 +++++++++++++-------------- docs/api/users.md | 76 +++++++++++++++++++------------------- 3 files changed, 94 insertions(+), 94 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3f15783069c28..ee42fb24e98af 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3337,7 +3337,7 @@ const docTemplate = `{ } } }, - "/users/{user}/keys/{keyid}": { + "/users/{user}/keys/tokens/{keyname}": { "get": { "security": [ { @@ -3350,8 +3350,8 @@ const docTemplate = `{ "tags": [ "Users" ], - "summary": "Get API key by ID", - "operationId": "get-api-key-by-id", + "summary": "Get API key by token name", + "operationId": "get-api-key-by-name", "parameters": [ { "type": "string", @@ -3362,9 +3362,9 @@ const docTemplate = `{ }, { "type": "string", - "format": "uuid", - "description": "Key ID", - "name": "keyid", + "format": "string", + "description": "Key Name", + "name": "keyname", "in": "path", "required": true } @@ -3377,18 +3377,23 @@ const docTemplate = `{ } } } - }, - "delete": { + } + }, + "/users/{user}/keys/{keyid}": { + "get": { "security": [ { "CoderSessionToken": [] } ], + "produces": [ + "application/json" + ], "tags": [ "Users" ], - "summary": "Delete API key", - "operationId": "delete-api-key", + "summary": "Get API key by ID", + "operationId": "get-api-key-by-id", "parameters": [ { "type": "string", @@ -3407,27 +3412,25 @@ const docTemplate = `{ } ], "responses": { - "204": { - "description": "No Content" + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.APIKey" + } } } - } - }, - "/users/{user}/keys/{keyname}": { - "get": { + }, + "delete": { "security": [ { "CoderSessionToken": [] } ], - "produces": [ - "application/json" - ], "tags": [ "Users" ], - "summary": "Get API key by token name", - "operationId": "get-api-key-by-name", + "summary": "Delete API key", + "operationId": "delete-api-key", "parameters": [ { "type": "string", @@ -3438,19 +3441,16 @@ const docTemplate = `{ }, { "type": "string", - "format": "string", - "description": "Key Name", - "name": "keyname", + "format": "uuid", + "description": "Key ID", + "name": "keyid", "in": "path", "required": true } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.APIKey" - } + "204": { + "description": "No Content" } } } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index e18f35477bf1c..be828df5204e6 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2931,7 +2931,7 @@ } } }, - "/users/{user}/keys/{keyid}": { + "/users/{user}/keys/tokens/{keyname}": { "get": { "security": [ { @@ -2940,8 +2940,8 @@ ], "produces": ["application/json"], "tags": ["Users"], - "summary": "Get API key by ID", - "operationId": "get-api-key-by-id", + "summary": "Get API key by token name", + "operationId": "get-api-key-by-name", "parameters": [ { "type": "string", @@ -2952,9 +2952,9 @@ }, { "type": "string", - "format": "uuid", - "description": "Key ID", - "name": "keyid", + "format": "string", + "description": "Key Name", + "name": "keyname", "in": "path", "required": true } @@ -2967,16 +2967,19 @@ } } } - }, - "delete": { + } + }, + "/users/{user}/keys/{keyid}": { + "get": { "security": [ { "CoderSessionToken": [] } ], + "produces": ["application/json"], "tags": ["Users"], - "summary": "Delete API key", - "operationId": "delete-api-key", + "summary": "Get API key by ID", + "operationId": "get-api-key-by-id", "parameters": [ { "type": "string", @@ -2995,23 +2998,23 @@ } ], "responses": { - "204": { - "description": "No Content" + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.APIKey" + } } } - } - }, - "/users/{user}/keys/{keyname}": { - "get": { + }, + "delete": { "security": [ { "CoderSessionToken": [] } ], - "produces": ["application/json"], "tags": ["Users"], - "summary": "Get API key by token name", - "operationId": "get-api-key-by-name", + "summary": "Delete API key", + "operationId": "delete-api-key", "parameters": [ { "type": "string", @@ -3022,19 +3025,16 @@ }, { "type": "string", - "format": "string", - "description": "Key Name", - "name": "keyname", + "format": "uuid", + "description": "Key ID", + "name": "keyid", "in": "path", "required": true } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.APIKey" - } + "204": { + "description": "No Content" } } } diff --git a/docs/api/users.md b/docs/api/users.md index d820d0e52291e..4c055609d093d 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -670,25 +670,25 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/keys/tokens \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Get API key by ID +## Get API key by token name ### Code samples ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/{keyid} \ +curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/tokens/{keyname} \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /users/{user}/keys/{keyid}` +`GET /users/{user}/keys/tokens/{keyname}` ### Parameters -| Name | In | Type | Required | Description | -| ------- | ---- | ------------ | -------- | -------------------- | -| `user` | path | string | true | User ID, name, or me | -| `keyid` | path | string(uuid) | true | Key ID | +| Name | In | Type | Required | Description | +| --------- | ---- | -------------- | -------- | -------------------- | +| `user` | path | string | true | User ID, name, or me | +| `keyname` | path | string(string) | true | Key Name | ### Example responses @@ -717,17 +717,18 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/{keyid} \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Delete API key +## Get API key by ID ### Code samples ```shell # Example request using curl -curl -X DELETE http://coder-server:8080/api/v2/users/{user}/keys/{keyid} \ +curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/{keyid} \ + -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`DELETE /users/{user}/keys/{keyid}` +`GET /users/{user}/keys/{keyid}` ### Parameters @@ -736,34 +737,6 @@ curl -X DELETE http://coder-server:8080/api/v2/users/{user}/keys/{keyid} \ | `user` | path | string | true | User ID, name, or me | | `keyid` | path | string(uuid) | true | Key ID | -### Responses - -| Status | Meaning | Description | Schema | -| ------ | --------------------------------------------------------------- | ----------- | ------ | -| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - -## Get API key by token name - -### Code samples - -```shell -# Example request using curl -curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/{keyname} \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`GET /users/{user}/keys/{keyname}` - -### Parameters - -| Name | In | Type | Required | Description | -| --------- | ---- | -------------- | -------- | -------------------- | -| `user` | path | string | true | User ID, name, or me | -| `keyname` | path | string(string) | true | Key Name | - ### Example responses > 200 Response @@ -791,6 +764,33 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/{keyname} \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Delete API key + +### Code samples + +```shell +# Example request using curl +curl -X DELETE http://coder-server:8080/api/v2/users/{user}/keys/{keyid} \ + -H 'Coder-Session-Token: API_KEY' +``` + +`DELETE /users/{user}/keys/{keyid}` + +### Parameters + +| Name | In | Type | Required | Description | +| ------- | ---- | ------------ | -------- | -------------------- | +| `user` | path | string | true | User ID, name, or me | +| `keyid` | path | string(uuid) | true | Key ID | + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | --------------------------------------------------------------- | ----------- | ------ | +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get organizations by user ### Code samples From 269f2baa91b1d1bbe18fd5a1f44e28ba2cc600a7 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 1 Mar 2023 12:42:16 -0600 Subject: [PATCH 16/23] test: Fix dbfake to insert token name --- coderd/database/dbfake/databasefake.go | 1 + 1 file changed, 1 insertion(+) diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index 9062b630ee939..706d3fa07750f 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -2525,6 +2525,7 @@ func (q *fakeQuerier) InsertAPIKey(_ context.Context, arg database.InsertAPIKeyP LastUsed: arg.LastUsed, LoginType: arg.LoginType, Scope: arg.Scope, + TokenName: arg.TokenName, } q.apiKeys = append(q.apiKeys, key) return key, nil From dd01f53d50dd2c349be710f3869397e68c03b3b1 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Wed, 1 Mar 2023 20:30:50 +0000 Subject: [PATCH 17/23] fix doc tests --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- coderd/apikey.go | 2 +- coderd/userauth.go | 2 -- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b2a990957415b..213f001b1ffb4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3351,7 +3351,7 @@ const docTemplate = `{ "Users" ], "summary": "Get API key by token name", - "operationId": "get-api-key-by-name", + "operationId": "get-api-key-by-token-name", "parameters": [ { "type": "string", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 6fe3fe761af4a..f1251252a6d4d 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2941,7 +2941,7 @@ "produces": ["application/json"], "tags": ["Users"], "summary": "Get API key by token name", - "operationId": "get-api-key-by-name", + "operationId": "get-api-key-by-token-name", "parameters": [ { "type": "string", diff --git a/coderd/apikey.go b/coderd/apikey.go index 598be7f29ce68..c062f7de765f1 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -186,7 +186,7 @@ func (api *API) apiKeyByID(rw http.ResponseWriter, r *http.Request) { } // @Summary Get API key by token name -// @ID get-api-key-by-name +// @ID get-api-key-by-token-name // @Security CoderSessionToken // @Produce json // @Tags Users diff --git a/coderd/userauth.go b/coderd/userauth.go index aeb5bd4c64598..1a1ad6ad53b2e 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -132,7 +132,6 @@ func (api *API) postLogin(rw http.ResponseWriter, r *http.Request) { UserID: user.ID, LoginType: database.LoginTypePassword, RemoteAddr: r.RemoteAddr, - TokenName: namesgenerator.GetRandomName(1), }) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -921,7 +920,6 @@ func (api *API) oauthLogin(r *http.Request, params oauthLoginParams) (*http.Cook UserID: user.ID, LoginType: params.LoginType, RemoteAddr: r.RemoteAddr, - TokenName: namesgenerator.GetRandomName(1), }) if err != nil { return nil, database.APIKey{}, xerrors.Errorf("create API key: %w", err) From e8d519fb2f328e1b27ce64e4fcc9a839e14820b7 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Thu, 2 Mar 2023 07:27:30 -0800 Subject: [PATCH 18/23] Update cli/tokens.go Co-authored-by: Steven Masley --- cli/tokens.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/tokens.go b/cli/tokens.go index 3bd8393f84a56..3d51e0f1ff59d 100644 --- a/cli/tokens.go +++ b/cli/tokens.go @@ -191,7 +191,7 @@ func removeToken() *cobra.Command { token, err := client.APIKeyByName(cmd.Context(), codersdk.Me, args[0]) if err != nil { - return xerrors.Errorf("delete api key: %w", err) + return xerrors.Errorf("fetch api key by name %s: %w", args[0], err) } err = client.DeleteAPIKey(cmd.Context(), codersdk.Me, token.ID) From 4c974c46ac850d9e205c292e71d58d3394a495df Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Thu, 2 Mar 2023 07:28:58 -0800 Subject: [PATCH 19/23] Update coderd/database/migrations/000102_add_apikey_name.down.sql Co-authored-by: Steven Masley --- .../database/migrations/000102_add_apikey_name.down.sql | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/coderd/database/migrations/000102_add_apikey_name.down.sql b/coderd/database/migrations/000102_add_apikey_name.down.sql index 480c59da9f59a..f7070bd3637e9 100644 --- a/coderd/database/migrations/000102_add_apikey_name.down.sql +++ b/coderd/database/migrations/000102_add_apikey_name.down.sql @@ -1,2 +1,8 @@ +BEGIN; + +DROP INDEX idx_api_key_name; + ALTER TABLE ONLY api_keys - DROP COLUMN IF EXISTS token_name, + DROP COLUMN IF EXISTS token_name; + +COMMIT; From 4bf374d2cab58efcfed70bf09e5189846f7da735 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Thu, 2 Mar 2023 15:45:34 +0000 Subject: [PATCH 20/23] add more specificity to IsUniqueViolation check --- coderd/apikey.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/apikey.go b/coderd/apikey.go index c062f7de765f1..3977fe8bc9b0e 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -87,7 +87,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { TokenName: tokenName, }) if err != nil { - if database.IsUniqueViolation(err) { + if database.IsUniqueViolation(err, database.UniqueIndexApiKeyName) { httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ Message: fmt.Sprintf("A token with name %q already exists.", tokenName), Validations: []codersdk.ValidationError{{ From 07acbff2fc8343082050d54c5a2853acb201af11 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Thu, 2 Mar 2023 16:53:51 +0000 Subject: [PATCH 21/23] fix tests --- cli/testdata/coder_tokens_remove_--help.golden | 2 +- cli/tokens_test.go | 4 ++-- coderd/coderdtest/authorize.go | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cli/testdata/coder_tokens_remove_--help.golden b/cli/testdata/coder_tokens_remove_--help.golden index a0d46d2321b67..6edefb3fec3ee 100644 --- a/cli/testdata/coder_tokens_remove_--help.golden +++ b/cli/testdata/coder_tokens_remove_--help.golden @@ -1,7 +1,7 @@ Delete a token Usage: - coder tokens remove [id] [flags] + coder tokens remove [name] [flags] Aliases: remove, rm diff --git a/cli/tokens_test.go b/cli/tokens_test.go index 6a30812d7bceb..aaeacc75edfbd 100644 --- a/cli/tokens_test.go +++ b/cli/tokens_test.go @@ -33,7 +33,7 @@ func TestTokens(t *testing.T) { res := buf.String() require.Contains(t, res, "tokens found") - cmd, root = clitest.New(t, "tokens", "create") + cmd, root = clitest.New(t, "tokens", "create", "--name", "token-one") clitest.SetupConfig(t, client, root) buf = new(bytes.Buffer) cmd.SetOut(buf) @@ -73,7 +73,7 @@ func TestTokens(t *testing.T) { require.Len(t, tokens, 1) require.Equal(t, id, tokens[0].ID) - cmd, root = clitest.New(t, "tokens", "rm", id) + cmd, root = clitest.New(t, "tokens", "rm", "token-one") clitest.SetupConfig(t, client, root) buf = new(bytes.Buffer) cmd.SetOut(buf) diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index ceba9d6879a5e..4c7814317a77c 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -423,6 +423,7 @@ func NewAuthTester(ctx context.Context, t *testing.T, client *codersdk.Client, a "{templatename}": template.Name, "{workspace_and_agent}": workspace.Name + "." + workspace.LatestBuild.Resources[0].Agents[0].Name, "{keyid}": apiKey.ID, + "{keyname}": apiKey.TokenName, // Only checking template scoped params here "parameters/{scope}/{id}": fmt.Sprintf("parameters/%s/%s", string(templateParam.Scope), templateParam.ScopeID.String()), From aa779d9e184a87d6b306fab6a0bc2603985f3df9 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 2 Mar 2023 10:56:04 -0600 Subject: [PATCH 22/23] Fix AutorizeAllEndpoints --- coderd/coderdtest/authorize.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 4c7814317a77c..7b8e33140771f 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -346,8 +346,9 @@ func NewAuthTester(ctx context.Context, t *testing.T, client *codersdk.Client, a t.Fail() } _, err := client.CreateToken(ctx, admin.UserID.String(), codersdk.CreateTokenRequest{ - Lifetime: time.Hour, - Scope: codersdk.APIKeyScopeAll, + Lifetime: time.Hour, + Scope: codersdk.APIKeyScopeAll, + TokenName: namesgenerator.GetRandomName(1), }) require.NoError(t, err, "create token") From 6cd9329e00d1e81a8a28b4f1bfd9f64f3f5c6de5 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Thu, 2 Mar 2023 17:00:36 +0000 Subject: [PATCH 23/23] rename migration --- ...2_add_apikey_name.down.sql => 000103_add_apikey_name.down.sql} | 0 ...00102_add_apikey_name.up.sql => 000103_add_apikey_name.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000102_add_apikey_name.down.sql => 000103_add_apikey_name.down.sql} (100%) rename coderd/database/migrations/{000102_add_apikey_name.up.sql => 000103_add_apikey_name.up.sql} (100%) diff --git a/coderd/database/migrations/000102_add_apikey_name.down.sql b/coderd/database/migrations/000103_add_apikey_name.down.sql similarity index 100% rename from coderd/database/migrations/000102_add_apikey_name.down.sql rename to coderd/database/migrations/000103_add_apikey_name.down.sql diff --git a/coderd/database/migrations/000102_add_apikey_name.up.sql b/coderd/database/migrations/000103_add_apikey_name.up.sql similarity index 100% rename from coderd/database/migrations/000102_add_apikey_name.up.sql rename to coderd/database/migrations/000103_add_apikey_name.up.sql