Skip to content

fix: fix loading states and permissions checks in organization settings #16465

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 25 commits into from
Feb 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 15 additions & 16 deletions coderd/rbac/roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -297,18 +297,17 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
Identifier: RoleAuditor(),
DisplayName: "Auditor",
Site: Permissions(map[string][]policy.Action{
// Should be able to read all template details, even in orgs they
// are not in.
ResourceTemplate.Type: {policy.ActionRead, policy.ActionViewInsights},
ResourceAuditLog.Type: {policy.ActionRead},
ResourceUser.Type: {policy.ActionRead},
ResourceGroup.Type: {policy.ActionRead},
ResourceGroupMember.Type: {policy.ActionRead},
ResourceAuditLog.Type: {policy.ActionRead},
// Allow auditors to see the resources that audit logs reflect.
ResourceTemplate.Type: {policy.ActionRead, policy.ActionViewInsights},
ResourceUser.Type: {policy.ActionRead},
ResourceGroup.Type: {policy.ActionRead},
ResourceGroupMember.Type: {policy.ActionRead},
ResourceOrganization.Type: {policy.ActionRead},
ResourceOrganizationMember.Type: {policy.ActionRead},
// Allow auditors to query deployment stats and insights.
ResourceDeploymentStats.Type: {policy.ActionRead},
ResourceDeploymentConfig.Type: {policy.ActionRead},
// Org roles are not really used yet, so grant the perm at the site level.
ResourceOrganizationMember.Type: {policy.ActionRead},
}),
Org: map[string][]Permission{},
User: []Permission{},
Expand All @@ -325,11 +324,10 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
// CRUD to provisioner daemons for now.
ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
// Needs to read all organizations since
ResourceOrganization.Type: {policy.ActionRead},
ResourceUser.Type: {policy.ActionRead},
ResourceGroup.Type: {policy.ActionRead},
ResourceGroupMember.Type: {policy.ActionRead},
// Org roles are not really used yet, so grant the perm at the site level.
ResourceUser.Type: {policy.ActionRead},
ResourceGroup.Type: {policy.ActionRead},
ResourceGroupMember.Type: {policy.ActionRead},
ResourceOrganization.Type: {policy.ActionRead},
ResourceOrganizationMember.Type: {policy.ActionRead},
}),
Org: map[string][]Permission{},
Expand All @@ -348,10 +346,11 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete,
policy.ActionUpdatePersonal, policy.ActionReadPersonal,
},
ResourceGroup.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
ResourceGroupMember.Type: {policy.ActionRead},
ResourceOrganization.Type: {policy.ActionRead},
// Full perms to manage org members
ResourceOrganizationMember.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
ResourceGroup.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
ResourceGroupMember.Type: {policy.ActionRead},
// Manage org membership based on OIDC claims
ResourceIdpsyncSettings.Type: {policy.ActionRead, policy.ActionUpdate},
}),
Expand Down
5 changes: 3 additions & 2 deletions coderd/rbac/roles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ func TestRolePermissions(t *testing.T) {
owner := authSubject{Name: "owner", Actor: rbac.Subject{ID: adminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleOwner()}}}
templateAdmin := authSubject{Name: "template-admin", Actor: rbac.Subject{ID: templateAdminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleTemplateAdmin()}}}
userAdmin := authSubject{Name: "user-admin", Actor: rbac.Subject{ID: userAdminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleUserAdmin()}}}
auditor := authSubject{Name: "auditor", Actor: rbac.Subject{ID: auditorID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleAuditor()}}}

orgAdmin := authSubject{Name: "org_admin", Actor: rbac.Subject{ID: adminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgMember(orgID), rbac.ScopedRoleOrgAdmin(orgID)}}}
orgAuditor := authSubject{Name: "org_auditor", Actor: rbac.Subject{ID: auditorID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgMember(orgID), rbac.ScopedRoleOrgAuditor(orgID)}}}
Expand Down Expand Up @@ -286,8 +287,8 @@ func TestRolePermissions(t *testing.T) {
Actions: []policy.Action{policy.ActionRead},
Resource: rbac.ResourceOrganization.WithID(orgID).InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, orgMemberMe, templateAdmin, orgTemplateAdmin, orgAuditor, orgUserAdmin},
false: {setOtherOrg, memberMe, userAdmin},
true: {owner, orgAdmin, orgMemberMe, templateAdmin, orgTemplateAdmin, auditor, orgAuditor, userAdmin, orgUserAdmin},
false: {setOtherOrg, memberMe},
},
},
{
Expand Down
127 changes: 28 additions & 99 deletions site/src/api/queries/organizations.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { API } from "api/api";
import type {
AuthorizationResponse,
CreateOrganizationRequest,
GroupSyncSettings,
RoleSyncSettings,
UpdateOrganizationRequest,
} from "api/typesGenerated";
import {
type AnyOrganizationPermissions,
type OrganizationPermissionName,
type OrganizationPermissions,
anyOrganizationPermissionChecks,
organizationPermissionChecks,
} from "modules/management/organizationPermissions";
import type { QueryClient } from "react-query";
import { meKey } from "./users";

Expand Down Expand Up @@ -197,53 +203,6 @@ export const patchRoleSyncSettings = (
};
};

/**
* Fetch permissions for a single organization.
*
* If the ID is undefined, return a disabled query.
*/
export const organizationPermissions = (organizationId: string | undefined) => {
if (!organizationId) {
return { enabled: false };
}
return {
queryKey: ["organization", organizationId, "permissions"],
queryFn: () =>
// Only request what we use on individual org settings, members, and group
// pages, which at the moment is whether you can edit the members on the
// members page, create roles on the roles page, and create groups on the
// groups page. The edit organization check for the settings page is
// covered by the multi-org query at the moment, and the edit group check
// on the group page is done on the group itself, not the org, so neither
// show up here.
API.checkAuthorization({
checks: {
editMembers: {
object: {
resource_type: "organization_member",
organization_id: organizationId,
},
action: "update",
},
createGroup: {
object: {
resource_type: "group",
organization_id: organizationId,
},
action: "create",
},
assignOrgRole: {
object: {
resource_type: "assign_org_role",
organization_id: organizationId,
},
action: "create",
},
},
}),
};
};

export const provisionerJobQueryKey = (orgId: string) => [
"organization",
orgId,
Expand Down Expand Up @@ -276,58 +235,13 @@ export const organizationsPermissions = (
// per sub-link (settings, groups, roles, and members pages) that tells us
// whether to show that page, since we only show them if you can edit (and
// not, at the moment if you can only view).
const checks = (organizationId: string) => ({
editMembers: {
object: {
resource_type: "organization_member",
organization_id: organizationId,
},
action: "update",
},
editGroups: {
object: {
resource_type: "group",
organization_id: organizationId,
},
action: "update",
},
editOrganization: {
object: {
resource_type: "organization",
organization_id: organizationId,
},
action: "update",
},
assignOrgRole: {
object: {
resource_type: "assign_org_role",
organization_id: organizationId,
},
action: "create",
},
viewProvisioners: {
object: {
resource_type: "provisioner_daemon",
organization_id: organizationId,
},
action: "read",
},
viewIdpSyncSettings: {
object: {
resource_type: "idpsync_settings",
organization_id: organizationId,
},
action: "read",
},
});

// The endpoint takes a flat array, so to avoid collisions prepend each
// check with the org ID (the key can be anything we want).
const prefixedChecks = organizationIds.flatMap((orgId) =>
Object.entries(checks(orgId)).map(([key, val]) => [
`${orgId}.${key}`,
val,
]),
Object.entries(organizationPermissionChecks(orgId)).map(
([key, val]) => [`${orgId}.${key}`, val],
),
);

const response = await API.checkAuthorization({
Expand All @@ -343,15 +257,30 @@ export const organizationsPermissions = (
if (!acc[orgId]) {
acc[orgId] = {};
}
acc[orgId][perm] = value;
acc[orgId][perm as OrganizationPermissionName] = value;
return acc;
},
{} as Record<string, AuthorizationResponse>,
);
{} as Record<string, Partial<OrganizationPermissions>>,
) as Record<string, OrganizationPermissions>;
},
};
};

export const anyOrganizationPermissionsKey = [
"authorization",
"anyOrganization",
];

export const anyOrganizationPermissions = () => {
return {
queryKey: anyOrganizationPermissionsKey,
queryFn: () =>
API.checkAuthorization({
checks: anyOrganizationPermissionChecks,
}) as Promise<AnyOrganizationPermissions>,
};
};

export const getOrganizationIdpSyncClaimFieldValuesKey = (
organization: string,
field: string,
Expand Down
8 changes: 0 additions & 8 deletions site/src/contexts/auth/permissions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ export const checks = {
readWorkspaceProxies: "readWorkspaceProxies",
editWorkspaceProxies: "editWorkspaceProxies",
createOrganization: "createOrganization",
editAnyOrganization: "editAnyOrganization",
viewAnyGroup: "viewAnyGroup",
createGroup: "createGroup",
viewAllLicenses: "viewAllLicenses",
Expand Down Expand Up @@ -122,13 +121,6 @@ export const permissionsToCheck = {
},
action: "create",
},
[checks.editAnyOrganization]: {
object: {
resource_type: "organization",
any_org: true,
},
action: "update",
},
[checks.viewAnyGroup]: {
object: {
resource_type: "group",
Expand Down
22 changes: 18 additions & 4 deletions site/src/modules/dashboard/DashboardProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { appearance } from "api/queries/appearance";
import { entitlements } from "api/queries/entitlements";
import { experiments } from "api/queries/experiments";
import { organizations } from "api/queries/organizations";
import {
anyOrganizationPermissions,
organizations,
} from "api/queries/organizations";
import type {
AppearanceConfig,
Entitlements,
Expand All @@ -11,6 +14,7 @@ import type {
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import { canViewAnyOrganization } from "modules/management/organizationPermissions";
import { type FC, type PropsWithChildren, createContext } from "react";
import { useQuery } from "react-query";
import { selectFeatureVisibility } from "./entitlements";
Expand All @@ -21,6 +25,7 @@ export interface DashboardValue {
appearance: AppearanceConfig;
organizations: readonly Organization[];
showOrganizations: boolean;
canViewOrganizationSettings: boolean;
}

export const DashboardContext = createContext<DashboardValue | undefined>(
Expand All @@ -33,12 +38,16 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
const experimentsQuery = useQuery(experiments(metadata.experiments));
const appearanceQuery = useQuery(appearance(metadata.appearance));
const organizationsQuery = useQuery(organizations());
const anyOrganizationPermissionsQuery = useQuery(
anyOrganizationPermissions(),
);

const error =
entitlementsQuery.error ||
appearanceQuery.error ||
experimentsQuery.error ||
organizationsQuery.error;
organizationsQuery.error ||
anyOrganizationPermissionsQuery.error;

if (error) {
return <ErrorAlert error={error} />;
Expand All @@ -48,7 +57,8 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
!entitlementsQuery.data ||
!appearanceQuery.data ||
!experimentsQuery.data ||
!organizationsQuery.data;
!organizationsQuery.data ||
!anyOrganizationPermissionsQuery.data;

if (isLoading) {
return <Loader fullscreen />;
Expand All @@ -58,6 +68,7 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
const organizationsEnabled = selectFeatureVisibility(
entitlementsQuery.data,
).multiple_organizations;
const showOrganizations = hasMultipleOrganizations || organizationsEnabled;

return (
<DashboardContext.Provider
Expand All @@ -66,7 +77,10 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
experiments: experimentsQuery.data,
appearance: appearanceQuery.data,
organizations: organizationsQuery.data,
showOrganizations: hasMultipleOrganizations || organizationsEnabled,
showOrganizations,
canViewOrganizationSettings:
showOrganizations &&
canViewAnyOrganization(anyOrganizationPermissionsQuery.data),
}}
>
{children}
Expand Down
9 changes: 4 additions & 5 deletions site/src/modules/dashboard/Navbar/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,13 @@ export const Navbar: FC = () => {
const { metadata } = useEmbeddedMetadata();
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));

const { appearance, showOrganizations } = useDashboard();
const { appearance, canViewOrganizationSettings } = useDashboard();
const { user: me, permissions, signOut } = useAuthenticated();
const featureVisibility = useFeatureVisibility();
const canViewAuditLog =
featureVisibility.audit_log && Boolean(permissions.viewAnyAuditLog);
const canViewDeployment = Boolean(permissions.viewDeploymentValues);
const canViewOrganizations =
Boolean(permissions.editAnyOrganization) && showOrganizations;
featureVisibility.audit_log && permissions.viewAnyAuditLog;
const canViewDeployment = permissions.viewDeploymentValues;
const canViewOrganizations = canViewOrganizationSettings;
const proxyContextValue = useProxy();
const canViewHealth = canViewDeployment;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { Stack } from "components/Stack/Stack";
import { usePopover } from "components/deprecated/Popover/Popover";
import type { FC } from "react";
import { Link } from "react-router-dom";

export const Language = {
accountLabel: "Account",
signOutLabel: "Sign Out",
Expand Down Expand Up @@ -129,7 +130,7 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
</a>
</Tooltip>

{Boolean(buildInfo?.deployment_id) && (
{buildInfo?.deployment_id && (
<div
css={css`
font-size: 12px;
Expand All @@ -145,11 +146,11 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
text-overflow: ellipsis;
`}
>
{buildInfo?.deployment_id}
{buildInfo.deployment_id}
</div>
</Tooltip>
<CopyButton
text={buildInfo!.deployment_id}
text={buildInfo.deployment_id}
buttonStyles={css`
width: 16px;
height: 16px;
Expand Down
Loading
Loading