-
Notifications
You must be signed in to change notification settings - Fork 899
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
Changes from 23 commits
1ad09fa
620bd3a
7ee3cf0
e889621
36e0376
032a75c
eea8bbd
e6d4201
14fc68e
a8fd9e6
0a36cd1
e6d772b
3752af5
2068a4c
2fe3d53
3c41de0
162642e
64c6f29
e12a4aa
b9b33c0
f7df5ff
6925294
9fc0afa
498e7ba
234c606
4496a75
9fcf3e7
19fb6e5
804255e
d341175
f3e6659
ce67a66
7e946a7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,18 +3,20 @@ import { Navigate } from "react-router-dom"; | |
|
||
export interface RequirePermissionProps { | ||
children?: ReactNode; | ||
isFeatureVisible: boolean; | ||
permitted: boolean; | ||
unpermittedRedirect?: `/${string}`; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: Maybe |
||
} | ||
|
||
/** | ||
* 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 />; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}</>; | ||
|
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", | ||||||
|
@@ -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; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||||||
|
||||||
export const permissionsToCheck = { | ||||||
[checks.viewAllUsers]: { | ||||||
|
@@ -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", | ||||||
|
@@ -116,7 +128,6 @@ export const permissionsToCheck = { | |||||
[checks.viewAnyGroup]: { | ||||||
object: { | ||||||
resource_type: "group", | ||||||
org_id: "any", | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you delete this unused check then? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, sorry – I meant that the |
||||||
}, | ||||||
action: "read", | ||||||
}, | ||||||
|
@@ -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>; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this value is already provided by |
||
showOrganizations: boolean; | ||
} | ||
|
||
|
@@ -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 || | ||
|
@@ -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} | ||
|
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(); | ||
}); | ||
}); | ||
}); |
There was a problem hiding this comment.
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...