mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: implement OAuth2App* page permissions (#21911)
This pull-request implements various permission checks to the `<OAuth2App* />` stories and components. We're trying to ensure that we're actually allowed to `create`/`view`/`delete` on both Secrets and Applications before showing them to the user/allowing action. Furthermore, I've added various stories to catch when a user lacks these permissions. I noticed this particularly because I'm only an `Auditor` on our DEV instance and can't see these fields. --------- Co-authored-by: coder-tasks[bot] <254784001+coder-tasks[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
coder-tasks[bot]
parent
af0e171595
commit
849eaccd78
@@ -176,6 +176,30 @@ export const permissionChecks = {
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
createOAuth2App: {
|
||||
object: {
|
||||
resource_type: "oauth2_app",
|
||||
},
|
||||
action: "create",
|
||||
},
|
||||
editOAuth2App: {
|
||||
object: {
|
||||
resource_type: "oauth2_app",
|
||||
},
|
||||
action: "update",
|
||||
},
|
||||
deleteOAuth2App: {
|
||||
object: {
|
||||
resource_type: "oauth2_app",
|
||||
},
|
||||
action: "delete",
|
||||
},
|
||||
viewOAuth2AppSecrets: {
|
||||
object: {
|
||||
resource_type: "oauth2_app_secret",
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
} as const satisfies Record<string, AuthorizationCheck>;
|
||||
|
||||
export const canViewDeploymentSettings = (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { postApp } from "api/queries/oauth2";
|
||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { useAuthenticated } from "hooks";
|
||||
import type { FC } from "react";
|
||||
import { useMutation, useQueryClient } from "react-query";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
@@ -9,8 +10,10 @@ import { CreateOAuth2AppPageView } from "./CreateOAuth2AppPageView";
|
||||
const CreateOAuth2AppPage: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { permissions } = useAuthenticated();
|
||||
const queryClient = useQueryClient();
|
||||
const postAppMutation = useMutation(postApp(queryClient));
|
||||
const canCreateApp = permissions.createOAuth2App;
|
||||
|
||||
const defaultValues = {
|
||||
name: searchParams.get("name") ?? "",
|
||||
@@ -37,6 +40,7 @@ const CreateOAuth2AppPage: FC = () => {
|
||||
displayError("Failed to create OAuth2 application");
|
||||
}
|
||||
}}
|
||||
canCreateApp={canCreateApp}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+9
@@ -5,6 +5,9 @@ import { CreateOAuth2AppPageView } from "./CreateOAuth2AppPageView";
|
||||
const meta: Meta = {
|
||||
title: "pages/DeploymentSettingsPage/CreateOAuth2AppPageView",
|
||||
component: CreateOAuth2AppPageView,
|
||||
args: {
|
||||
canCreateApp: true,
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
|
||||
@@ -38,4 +41,10 @@ export const WithError: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const NoPermissions: Story = {
|
||||
args: {
|
||||
canCreateApp: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
+3
@@ -21,6 +21,7 @@ type CreateOAuth2AppProps = {
|
||||
callback_url: string;
|
||||
icon: string;
|
||||
};
|
||||
canCreateApp: boolean;
|
||||
};
|
||||
|
||||
export const CreateOAuth2AppPageView: FC<CreateOAuth2AppProps> = ({
|
||||
@@ -28,6 +29,7 @@ export const CreateOAuth2AppPageView: FC<CreateOAuth2AppProps> = ({
|
||||
createApp,
|
||||
error,
|
||||
defaultValues,
|
||||
canCreateApp,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
@@ -58,6 +60,7 @@ export const CreateOAuth2AppPageView: FC<CreateOAuth2AppProps> = ({
|
||||
isUpdating={isUpdating}
|
||||
error={error}
|
||||
defaultValues={defaultValues}
|
||||
disabled={!canCreateApp}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as oauth2 from "api/queries/oauth2";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { useAuthenticated } from "hooks";
|
||||
import { type FC, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
@@ -9,6 +10,7 @@ import { EditOAuth2AppPageView } from "./EditOAuth2AppPageView";
|
||||
|
||||
const EditOAuth2AppPage: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { permissions } = useAuthenticated();
|
||||
const { appId } = useParams() as { appId: string };
|
||||
|
||||
// When a new secret is created it is returned with the full secret. This is
|
||||
@@ -22,7 +24,10 @@ const EditOAuth2AppPage: FC = () => {
|
||||
const appQuery = useQuery(oauth2.getApp(appId));
|
||||
const putAppMutation = useMutation(oauth2.putApp(queryClient));
|
||||
const deleteAppMutation = useMutation(oauth2.deleteApp(queryClient));
|
||||
const secretsQuery = useQuery(oauth2.getAppSecrets(appId));
|
||||
const secretsQuery = useQuery({
|
||||
...oauth2.getAppSecrets(appId),
|
||||
enabled: permissions.viewOAuth2AppSecrets,
|
||||
});
|
||||
const postSecretMutation = useMutation(oauth2.postAppSecret(queryClient));
|
||||
const deleteSecretMutation = useMutation(oauth2.deleteAppSecret(queryClient));
|
||||
|
||||
@@ -94,6 +99,9 @@ const EditOAuth2AppPage: FC = () => {
|
||||
displayError("Failed to delete OAuth2 client secret");
|
||||
}
|
||||
}}
|
||||
canEditApp={permissions.editOAuth2App}
|
||||
canDeleteApp={permissions.deleteOAuth2App}
|
||||
canViewAppSecrets={permissions.viewOAuth2AppSecrets}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+5
@@ -9,6 +9,11 @@ import { EditOAuth2AppPageView } from "./EditOAuth2AppPageView";
|
||||
const meta: Meta = {
|
||||
title: "pages/DeploymentSettingsPage/EditOAuth2AppPageView",
|
||||
component: EditOAuth2AppPageView,
|
||||
args: {
|
||||
canEditApp: true,
|
||||
canDeleteApp: true,
|
||||
canViewAppSecrets: true,
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
|
||||
|
||||
+20
-8
@@ -49,6 +49,9 @@ type EditOAuth2AppProps = {
|
||||
deleteApp: (name: string) => void;
|
||||
generateAppSecret: () => void;
|
||||
deleteAppSecret: (id: string) => void;
|
||||
canEditApp: boolean;
|
||||
canDeleteApp: boolean;
|
||||
canViewAppSecrets: boolean;
|
||||
secrets?: readonly TypesGen.OAuth2ProviderAppSecret[];
|
||||
fullNewSecret?: TypesGen.OAuth2ProviderAppSecretFull;
|
||||
ackFullNewSecret: () => void;
|
||||
@@ -64,6 +67,9 @@ export const EditOAuth2AppPageView: FC<EditOAuth2AppProps> = ({
|
||||
deleteApp,
|
||||
generateAppSecret,
|
||||
deleteAppSecret,
|
||||
canEditApp,
|
||||
canDeleteApp,
|
||||
canViewAppSecrets,
|
||||
secrets,
|
||||
fullNewSecret,
|
||||
ackFullNewSecret,
|
||||
@@ -180,21 +186,27 @@ export const EditOAuth2AppPageView: FC<EditOAuth2AppProps> = ({
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setShowDelete(true)}
|
||||
disabled={!canDeleteApp}
|
||||
>
|
||||
Delete…
|
||||
</Button>
|
||||
}
|
||||
disabled={!canEditApp}
|
||||
/>
|
||||
|
||||
<Divider css={{ borderColor: theme.palette.divider }} />
|
||||
{canViewAppSecrets && (
|
||||
<>
|
||||
<Divider css={{ borderColor: theme.palette.divider }} />
|
||||
|
||||
<OAuth2AppSecretsTable
|
||||
secrets={secrets}
|
||||
generateAppSecret={generateAppSecret}
|
||||
deleteAppSecret={deleteAppSecret}
|
||||
isLoadingSecrets={isLoadingSecrets}
|
||||
mutatingResource={mutatingResource}
|
||||
/>
|
||||
<OAuth2AppSecretsTable
|
||||
secrets={secrets}
|
||||
generateAppSecret={generateAppSecret}
|
||||
deleteAppSecret={deleteAppSecret}
|
||||
isLoadingSecrets={isLoadingSecrets}
|
||||
mutatingResource={mutatingResource}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { isApiValidationError, mapApiErrorToFieldErrors } from "api/errors";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import type { FC, ReactNode } from "react";
|
||||
|
||||
type OAuth2AppFormProps = {
|
||||
@@ -21,6 +20,7 @@ type OAuth2AppFormProps = {
|
||||
callback_url: string;
|
||||
icon: string;
|
||||
};
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const OAuth2AppForm: FC<OAuth2AppFormProps> = ({
|
||||
@@ -30,6 +30,7 @@ export const OAuth2AppForm: FC<OAuth2AppFormProps> = ({
|
||||
isUpdating,
|
||||
actions,
|
||||
defaultValues,
|
||||
disabled,
|
||||
}) => {
|
||||
const apiValidationErrors = isApiValidationError(error)
|
||||
? mapApiErrorToFieldErrors(error.response.data)
|
||||
@@ -37,7 +38,7 @@ export const OAuth2AppForm: FC<OAuth2AppFormProps> = ({
|
||||
|
||||
return (
|
||||
<form
|
||||
css={{ marginTop: 10 }}
|
||||
className="mt-2.5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.target as HTMLFormElement);
|
||||
@@ -48,7 +49,7 @@ export const OAuth2AppForm: FC<OAuth2AppFormProps> = ({
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2.5}>
|
||||
<div className="flex flex-col gap-5">
|
||||
<TextField
|
||||
name="name"
|
||||
label="Application name"
|
||||
@@ -57,6 +58,7 @@ export const OAuth2AppForm: FC<OAuth2AppFormProps> = ({
|
||||
helperText={
|
||||
apiValidationErrors?.name || "The name of your Coder app."
|
||||
}
|
||||
disabled={disabled}
|
||||
autoFocus
|
||||
fullWidth
|
||||
/>
|
||||
@@ -69,6 +71,7 @@ export const OAuth2AppForm: FC<OAuth2AppFormProps> = ({
|
||||
apiValidationErrors?.callback_url ||
|
||||
"The full URL to redirect to after a user authorizes an installation."
|
||||
}
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
@@ -79,17 +82,18 @@ export const OAuth2AppForm: FC<OAuth2AppFormProps> = ({
|
||||
helperText={
|
||||
apiValidationErrors?.icon || "A full or relative URL to an icon."
|
||||
}
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Stack direction="row">
|
||||
<Button disabled={isUpdating} type="submit">
|
||||
<div className="flex flex-row gap-4">
|
||||
<Button disabled={isUpdating || disabled} type="submit">
|
||||
<Spinner loading={isUpdating} />
|
||||
{app ? "Update application" : "Create application"}
|
||||
</Button>
|
||||
{actions}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
+5
@@ -1,12 +1,16 @@
|
||||
import { getApps } from "api/queries/oauth2";
|
||||
import { useAuthenticated } from "hooks";
|
||||
import type { FC } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { pageTitle } from "utils/page";
|
||||
import OAuth2AppsSettingsPageView from "./OAuth2AppsSettingsPageView";
|
||||
|
||||
const OAuth2AppsSettingsPage: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
const appsQuery = useQuery(getApps());
|
||||
|
||||
const canCreateApp = permissions.createOAuth2App;
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{pageTitle("OAuth2 Applications")}</title>
|
||||
@@ -15,6 +19,7 @@ const OAuth2AppsSettingsPage: FC = () => {
|
||||
apps={appsQuery.data}
|
||||
isLoading={appsQuery.isLoading}
|
||||
error={appsQuery.error}
|
||||
canCreateApp={canCreateApp}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+9
@@ -5,6 +5,9 @@ import OAuth2AppsSettingsPageView from "./OAuth2AppsSettingsPageView";
|
||||
const meta: Meta = {
|
||||
title: "pages/DeploymentSettingsPage/OAuth2AppsSettingsPageView",
|
||||
component: OAuth2AppsSettingsPageView,
|
||||
args: {
|
||||
canCreateApp: true,
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
|
||||
@@ -35,3 +38,9 @@ export const Empty: Story = {
|
||||
isLoading: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoCreatePermissions: Story = {
|
||||
args: {
|
||||
canCreateApp: false,
|
||||
},
|
||||
};
|
||||
|
||||
+10
-6
@@ -28,12 +28,14 @@ type OAuth2AppsSettingsProps = {
|
||||
apps?: TypesGen.OAuth2ProviderApp[];
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
canCreateApp: boolean;
|
||||
};
|
||||
|
||||
const OAuth2AppsSettingsPageView: FC<OAuth2AppsSettingsProps> = ({
|
||||
apps,
|
||||
isLoading,
|
||||
error,
|
||||
canCreateApp,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
@@ -51,12 +53,14 @@ const OAuth2AppsSettingsPageView: FC<OAuth2AppsSettingsProps> = ({
|
||||
</SettingsHeader>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/deployment/oauth2-provider/apps/add">
|
||||
<PlusIcon />
|
||||
Add application
|
||||
</Link>
|
||||
</Button>
|
||||
{canCreateApp && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/deployment/oauth2-provider/apps/add">
|
||||
<PlusIcon />
|
||||
Add application
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{error && <ErrorAlert error={error} />}
|
||||
|
||||
@@ -3082,6 +3082,10 @@ export const MockPermissions: Permissions = {
|
||||
viewAnyIdpSyncSettings: true,
|
||||
viewAnyMembers: true,
|
||||
viewAnyAIBridgeInterception: true,
|
||||
createOAuth2App: true,
|
||||
editOAuth2App: true,
|
||||
deleteOAuth2App: true,
|
||||
viewOAuth2AppSecrets: true,
|
||||
};
|
||||
|
||||
export const MockNoPermissions: Permissions = {
|
||||
@@ -3111,6 +3115,10 @@ export const MockNoPermissions: Permissions = {
|
||||
viewAnyIdpSyncSettings: false,
|
||||
viewAnyMembers: false,
|
||||
viewAnyAIBridgeInterception: true,
|
||||
createOAuth2App: false,
|
||||
editOAuth2App: false,
|
||||
deleteOAuth2App: false,
|
||||
viewOAuth2AppSecrets: false,
|
||||
};
|
||||
|
||||
export const MockOrganizationPermissions: OrganizationPermissions = {
|
||||
|
||||
Reference in New Issue
Block a user