Skip to content

fix: ensure user admins can always see users table #15226

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 33 commits into from
Oct 29, 2024
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
1ad09fa
refactor: update layout so that sidebar always shows
Parkreiner Oct 24, 2024
620bd3a
refactor: consolidate logic more
Parkreiner Oct 24, 2024
7ee3cf0
fix: add redirect for base users
Parkreiner Oct 24, 2024
e889621
fix: update routing logic to not block on disabled queries
Parkreiner Oct 24, 2024
36e0376
fix: update type misalignment
Parkreiner Oct 24, 2024
032a75c
chore: format and tidy up
Parkreiner Oct 24, 2024
eea8bbd
fix: make sure you can't navigate directly to bare /deployment page
Parkreiner Oct 24, 2024
e6d4201
fix: add more granularity to redirect logic
Parkreiner Oct 24, 2024
14fc68e
fix: prevent infinite redirect
Parkreiner Oct 24, 2024
a8fd9e6
fix: update redirects for /organizations routes
Parkreiner Oct 24, 2024
0a36cd1
fix: format files
Parkreiner Oct 24, 2024
e6d772b
fix: tighten up types for permissions
Parkreiner Oct 25, 2024
3752af5
Merge branch 'main' into mes/deploy-bug
Parkreiner Oct 25, 2024
2068a4c
fix: update route checks to account for subroutes
Parkreiner Oct 25, 2024
2fe3d53
fix: add e2e check to ensure that redirect hasn't happened
Parkreiner Oct 25, 2024
3c41de0
fix: update stories to account for new dashboard logic
Parkreiner Oct 25, 2024
162642e
fix: update out-of-date comment
Parkreiner Oct 25, 2024
64c6f29
fix: update default value logic for storybook setup
Parkreiner Oct 25, 2024
e12a4aa
fix: update individual stories
Parkreiner Oct 25, 2024
b9b33c0
fix: finish storybook injection changes
Parkreiner Oct 25, 2024
f7df5ff
fix: add back separate empty state to satisfy tests
Parkreiner Oct 25, 2024
6925294
fix: add tests and update code to account for missing cases
Parkreiner Oct 25, 2024
9fc0afa
fix: apply formatting
Parkreiner Oct 25, 2024
498e7ba
break out `DeploymentSettingsProvider`
aslilac Oct 28, 2024
234c606
fix: apply changes from previous PR
Parkreiner Oct 28, 2024
4496a75
fix: apply formatting
Parkreiner Oct 28, 2024
9fcf3e7
Merge branch 'main' into mes/deploy-bug
Parkreiner Oct 28, 2024
19fb6e5
Merge branch 'lilac/deployment-settings-provider' into mes/deploy-bug
Parkreiner Oct 28, 2024
804255e
fix: delete invalid tests
Parkreiner Oct 28, 2024
d341175
fix: revert changes not caught in merge
Parkreiner Oct 28, 2024
f3e6659
fix: update spellcheck
Parkreiner Oct 28, 2024
ce67a66
fix: revert nits
Parkreiner Oct 28, 2024
7e946a7
fix: apply feedback
Parkreiner Oct 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,9 @@
"typesafe",
"unauthenticate",
"unconvert",
"unpermitted",
"untar",
"userauth",
"userspace",
"VMID",
"walkthrough",
Expand Down
1 change: 1 addition & 0 deletions site/e2e/global.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ test("setup deployment", async ({ page }) => {
expect(constants.license.split(".").length).toBe(3); // otherwise it's invalid

await page.goto("/deployment/licenses", { waitUntil: "domcontentloaded" });
await expect(page).toHaveTitle("License Settings - Coder");

await page.getByText("Add a license").click();
await page.getByRole("textbox").fill(constants.license);
Expand Down
8 changes: 3 additions & 5 deletions site/jest.setup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import "@testing-library/jest-dom";
import "jest-location-mock";
import { cleanup } from "@testing-library/react";
import crypto from "crypto";
import crypto from "node:crypto";
import { useMemo } from "react";
import type { Region } from "api/typesGenerated";
import type { ProxyLatencyReport } from "contexts/useProxyLatency";
Expand Down Expand Up @@ -48,9 +48,7 @@ global.ResizeObserver = require("resize-observer-polyfill");
// Polyfill the getRandomValues that is used on utils/random.ts
Object.defineProperty(global.self, "crypto", {
value: {
getRandomValues: function (buffer: Buffer) {
return crypto.randomFillSync(buffer);
},
getRandomValues: (b: NodeJS.ArrayBufferView) => crypto.randomFillSync(b),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you could even take this a step further and just say...

Suggested change
getRandomValues: (b: NodeJS.ArrayBufferView) => crypto.randomFillSync(b),
getRandomValues: crypto.randomFillSync,

},
});

Expand All @@ -72,5 +70,5 @@ afterEach(() => {
// Clean up after the tests are finished.
afterAll(() => server.close());

// This is needed because we are compiling under `--isolatedModules`
// biome-ignore lint/complexity/noUselessEmptyExport: This is needed because we are compiling under `--isolatedModules`
export {};
1 change: 1 addition & 0 deletions site/src/@types/storybook.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ declare module "@storybook/react" {
experiments?: Experiments;
showOrganizations?: boolean;
organizations?: Organization[];
activeOrganization?: Organization;
queries?: { key: QueryKey; data: unknown; isError?: boolean }[];
webSocket?: WebSocketEvent[];
user?: User;
Expand Down
10 changes: 6 additions & 4 deletions site/src/contexts/auth/RequirePermission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,20 @@ import { Navigate } from "react-router-dom";

export interface RequirePermissionProps {
children?: ReactNode;
isFeatureVisible: boolean;
permitted: boolean;
unpermittedRedirect?: `/${string}`;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Maybe redirectOnFailure?

}

/**
* Wraps routes that are available based on RBAC or licensing.
*/
export const RequirePermission: FC<RequirePermissionProps> = ({
children,
isFeatureVisible,
permitted,
unpermittedRedirect = "/workspaces",
}) => {
if (!isFeatureVisible) {
return <Navigate to="/workspaces" />;
if (!permitted) {
return <Navigate to={unpermittedRedirect} replace />;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kinda feel like this is a bad abstraction. The user has to pass is the condition and the URL to navigate to, so why would we not want to just do

if (!allowedToSeeThing) {
  return <Navigate to="/some/place" replace />
}

...at the origin?

I don't think redirects like this should really be the job of this component–especially since I think it's a better user story to show an error page if they wound up somewhere they shouldn't be instead of just shoving them somewhere they didn't expect to go with no explaination

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, that's a really fair call. I wouldn't be opposed to ripping this out, since the abstraction is so shallow

}

return <>{children}</>;
Expand Down
25 changes: 21 additions & 4 deletions site/src/contexts/auth/permissions.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { AuthorizationCheck } from "api/typesGenerated";

export const checks = {
viewAllUsers: "viewAllUsers",
updateUsers: "updateUsers",
Expand All @@ -11,13 +13,17 @@ export const checks = {
viewUpdateCheck: "viewUpdateCheck",
viewExternalAuthConfig: "viewExternalAuthConfig",
viewDeploymentStats: "viewDeploymentStats",
readWorkspaceProxies: "readWorkspaceProxies",
editWorkspaceProxies: "editWorkspaceProxies",
createOrganization: "createOrganization",
editAnyOrganization: "editAnyOrganization",
viewAnyGroup: "viewAnyGroup",
createGroup: "createGroup",
viewAllLicenses: "viewAllLicenses",
} as const;
viewNotificationTemplate: "viewNotificationTemplate",
} as const satisfies Record<string, string>;

type PermissionType = keyof typeof checks;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is technically incorrect, and only working because all of the key value pairs are symmetrical. what you really want is...

Suggested change
type PermissionType = keyof typeof checks;
type PermissionType = typeof checks[keyof typeof checks];


export const permissionsToCheck = {
[checks.viewAllUsers]: {
Expand Down Expand Up @@ -94,6 +100,12 @@ export const permissionsToCheck = {
},
action: "read",
},
[checks.readWorkspaceProxies]: {
object: {
resource_type: "workspace_proxy",
},
action: "read",
},
[checks.editWorkspaceProxies]: {
object: {
resource_type: "workspace_proxy",
Expand All @@ -116,7 +128,6 @@ export const permissionsToCheck = {
[checks.viewAnyGroup]: {
object: {
resource_type: "group",
org_id: "any",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ended up deleting this because the property wasn't compatible. It also didn't seem to be used anywhere

@Emyrk Not sure if we should update the types so that this can be added back

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you delete this unused check then?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, sorry – I meant that the org_id property specifically didn't seem to be used anywhere

},
action: "read",
},
Expand All @@ -132,6 +143,12 @@ export const permissionsToCheck = {
},
action: "read",
},
} as const;
[checks.viewNotificationTemplate]: {
object: {
resource_type: "notification_template",
},
action: "read",
},
} as const satisfies Record<PermissionType, AuthorizationCheck>;

export type Permissions = Record<keyof typeof permissionsToCheck, boolean>;
export type Permissions = Record<PermissionType, boolean>;
8 changes: 8 additions & 0 deletions site/src/modules/dashboard/DashboardProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ import { Loader } from "components/Loader/Loader";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import { type FC, type PropsWithChildren, createContext } from "react";
import { useQuery } from "react-query";
import { useParams } from "react-router-dom";
import { selectFeatureVisibility } from "./entitlements";

export interface DashboardValue {
entitlements: Entitlements;
experiments: Experiments;
appearance: AppearanceConfig;
organizations: readonly Organization[];
activeOrganization: Organization | undefined;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this value is already provided by ManagementSettingsLayout, and the concept of a "current organization" is only relevant inside that context. no need to add this.

showOrganizations: boolean;
}

Expand All @@ -33,6 +35,9 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
const experimentsQuery = useQuery(experiments(metadata.experiments));
const appearanceQuery = useQuery(appearance(metadata.appearance));
const organizationsQuery = useQuery(organizations());
const { organization: organizationName } = useParams() as {
organization?: string;
};

const error =
entitlementsQuery.error ||
Expand Down Expand Up @@ -67,6 +72,9 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
appearance: appearanceQuery.data,
organizations: organizationsQuery.data,
showOrganizations: hasMultipleOrganizations || organizationsEnabled,
activeOrganization: organizationsQuery.data?.find(
(org) => org.name === organizationName,
),
}}
>
{children}
Expand Down
188 changes: 188 additions & 0 deletions site/src/modules/management/ManagementSettingsLayout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { Permission } from "api/typesGenerated";
import type { Permissions } from "contexts/auth/permissions";
import { MockNoPermissions, MockPermissions } from "testHelpers/entities";
import { isManagementRoutePermitted } from "./ManagementSettingsLayout";

describe(isManagementRoutePermitted.name, () => {
describe("General behavior", () => {
it("Rejects malformed routes", () => {
const invalidRoutes: readonly string[] = [
// It's expected that the hostname will be stripped off
"https://dev.coder.com/deployment/licenses/add",

// Missing the leading /
"organizations",
];

for (const r of invalidRoutes) {
}

// Currently only checking whether route ends with /
const invalidRoute = "organizations";
expect(
isManagementRoutePermitted(invalidRoute, MockPermissions, true),
).toBe(false);
});
});

describe("Organization routes", () => {
it("Delegates to showOrganizations value for any /organizations routes", () => {
expect(
isManagementRoutePermitted("/organizations", MockNoPermissions, true),
).toBe(true);

expect(
isManagementRoutePermitted(
"/organizations/nested/arbitrarily",
MockNoPermissions,
true,
),
).toBe(true);

expect(
isManagementRoutePermitted(
"/organizations/sample-organization",
MockNoPermissions,
true,
),
).toBe(true);

expect(
isManagementRoutePermitted("/organizations", MockPermissions, false),
).toBe(false);
});
});

describe("Deployment routes", () => {
it("Will never let the user through if they have no active permissions", () => {
let result = isManagementRoutePermitted(
"/deployment",
MockNoPermissions,
true,
);
expect(result).toBe(false);

result = isManagementRoutePermitted(
"/deployment/licenses",
MockNoPermissions,
true,
);
expect(result).toBe(false);

result = isManagementRoutePermitted(
"/deployment/appearance",
MockNoPermissions,
false,
);
expect(result).toBe(false);
});

it("Will let users access base /deployment route if they have at least one permission", () => {
const mocks: readonly Permissions[] = [
MockPermissions,
{
...MockNoPermissions,
createGroup: true,
},
{
...MockNoPermissions,
createOrganization: true,
deleteTemplates: true,
},
{
...MockNoPermissions,
editDeploymentValues: true,
viewNotificationTemplate: true,
readWorkspaceProxies: true,
},
];

for (const m of mocks) {
expect(isManagementRoutePermitted("/deployment", m, true)).toBe(true);
expect(isManagementRoutePermitted("/deployment", m, false)).toBe(true);
}
});

it("Rejects unknown deployment routes", () => {
const sampleRoutes: readonly string[] = [
"/deployment/definitely-not-right",
"/deployment/what-is-this",
];

for (const r of sampleRoutes) {
const result = isManagementRoutePermitted(r, MockPermissions, true);
expect(result).toBe(false);
}
});

it("Supports deployment routes that are nested more than one level", () => {
const routes: readonly string[] = [
"/deployment/licenses/add",

// Including oauth2-provider routes, even though they're not
// currently exposed via the UI
"/deployment/oauth2-provider/apps",
"/deployment/oauth2-provider/apps/add",
];

for (const r of routes) {
let result = isManagementRoutePermitted(r, MockPermissions, true);
expect(result).toBe(true);

result = isManagementRoutePermitted(r, MockPermissions, false);
expect(result).toBe(true);
}
});

it("Granularly associates individual deployment routes with specific permissions", () => {
type PairTuple = readonly [
route: `/${string}`,
permissionKey: keyof Permissions,
];

const pairs: readonly PairTuple[] = [
["/general", "viewDeploymentValues"],
["/licenses", "viewAllLicenses"],
["/appearance", "editDeploymentValues"],
["/userauth", "viewDeploymentValues"],
["/external-auth", "viewDeploymentValues"],
["/network", "viewDeploymentValues"],
["/workspace-proxies", "readWorkspaceProxies"],
["/security", "viewDeploymentValues"],
["/observability", "viewDeploymentValues"],
["/users", "viewAllUsers"],
["/notifications", "viewNotificationTemplate"],
["/oauth2-provider", "viewExternalAuthConfig"],
];

for (const [route, permName] of pairs) {
// Using NoPermissions version as baseline to make sure that we
// don't get false positives from all permissions being set to
// true at the start
const mutablePermsCopy = { ...MockNoPermissions };
const fullRoute = `/deployment${route}`;

mutablePermsCopy[permName] = true;
let result = isManagementRoutePermitted(
fullRoute,
mutablePermsCopy,
true,
);
expect(result).toBe(true);

result = isManagementRoutePermitted(
`${fullRoute}/example-subpath`,
mutablePermsCopy,
true,
);
expect(result).toBe(true);

mutablePermsCopy[permName] = false;
result = isManagementRoutePermitted(fullRoute, mutablePermsCopy, true);
expect(result).toBe(false);
}

expect.hasAssertions();
});
});
});
Loading
Loading