Access Control: Add rolepicker when Fine Grained Access Control is enabled (#48347)

pull/49421/head
Brian Gann 3 years ago committed by GitHub
parent 060af782df
commit 5fc5899462
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 10
      public/app/core/components/RolePicker/RolePicker.tsx
  2. 9
      public/app/core/components/RolePicker/RolePickerMenu.tsx
  3. 3
      public/app/core/components/RolePicker/TeamRolePicker.tsx
  4. 35
      public/app/core/components/RolePicker/UserRolePicker.tsx
  5. 7
      public/app/core/components/RolePicker/api.ts
  6. 16
      public/app/core/components/Select/OrgPicker.tsx
  7. 96
      public/app/features/admin/UserOrgs.tsx
  8. 98
      public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx
  9. 15
      public/app/features/serviceaccounts/ServiceAccountsListPage.tsx
  10. 11
      public/app/types/serviceaccount.ts

@ -16,8 +16,9 @@ export interface Props {
disabled?: boolean;
builtinRolesDisabled?: boolean;
showBuiltInRole?: boolean;
onRolesChange: (newRoles: string[]) => void;
onRolesChange: (newRoles: Role[]) => void;
onBuiltinRoleChange?: (newRole: OrgRole) => void;
updateDisabled?: boolean;
}
export const RolePicker = ({
@ -30,6 +31,7 @@ export const RolePicker = ({
showBuiltInRole,
onRolesChange,
onBuiltinRoleChange,
updateDisabled,
}: Props): JSX.Element | null => {
const [isOpen, setOpen] = useState(false);
const [selectedRoles, setSelectedRoles] = useState<Role[]>(appliedRoles);
@ -39,8 +41,9 @@ export const RolePicker = ({
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
setSelectedBuiltInRole(builtInRole);
setSelectedRoles(appliedRoles);
}, [appliedRoles]);
}, [appliedRoles, builtInRole]);
useEffect(() => {
const dimensions = ref?.current?.getBoundingClientRect();
@ -94,7 +97,7 @@ export const RolePicker = ({
setSelectedBuiltInRole(role);
};
const onUpdate = (newRoles: string[], newBuiltInRole?: OrgRole) => {
const onUpdate = (newRoles: Role[], newBuiltInRole?: OrgRole) => {
if (onBuiltinRoleChange && newBuiltInRole) {
onBuiltinRoleChange(newBuiltInRole);
}
@ -144,6 +147,7 @@ export const RolePicker = ({
showGroups={query.length === 0 || query.trim() === ''}
builtinRolesDisabled={builtinRolesDisabled}
showBuiltInRole={showBuiltInRole}
updateDisabled={updateDisabled || false}
offset={offset}
/>
)}

@ -39,8 +39,9 @@ interface RolePickerMenuProps {
showBuiltInRole?: boolean;
onSelect: (roles: Role[]) => void;
onBuiltInRoleSelect?: (role: OrgRole) => void;
onUpdate: (newRoles: string[], newBuiltInRole?: OrgRole) => void;
onUpdate: (newRoles: Role[], newBuiltInRole?: OrgRole) => void;
onClear?: () => void;
updateDisabled?: boolean;
offset: number;
}
@ -55,6 +56,7 @@ export const RolePickerMenu = ({
onBuiltInRoleSelect,
onUpdate,
onClear,
updateDisabled,
offset,
}: RolePickerMenuProps): JSX.Element => {
const [selectedOptions, setSelectedOptions] = useState<Role[]>(appliedRoles);
@ -166,11 +168,12 @@ export const RolePickerMenu = ({
const onUpdateInternal = () => {
const selectedCustomRoles: string[] = [];
// TODO: needed?
for (const key in selectedOptions) {
const roleUID = selectedOptions[key]?.uid;
selectedCustomRoles.push(roleUID);
}
onUpdate(selectedCustomRoles, selectedBuiltInRole);
onUpdate(selectedOptions, selectedBuiltInRole);
};
return (
@ -270,7 +273,7 @@ export const RolePickerMenu = ({
Clear all
</Button>
<Button size="sm" onClick={onUpdateInternal}>
Update
{updateDisabled ? `Apply` : `Update`}
</Button>
</HorizontalGroup>
</div>

@ -4,6 +4,7 @@ import { useAsyncFn } from 'react-use';
import { Role } from 'app/types';
import { RolePicker } from './RolePicker';
// @ts-ignore
import { fetchTeamRoles, updateTeamRoles } from './api';
export interface Props {
@ -29,7 +30,7 @@ export const TeamRolePicker: FC<Props> = ({ teamId, orgId, roleOptions, disabled
getTeamRoles();
}, [orgId, teamId, getTeamRoles]);
const onRolesChange = async (roles: string[]) => {
const onRolesChange = async (roles: Role[]) => {
await updateTeamRoles(roles, teamId, orgId);
await getTeamRoles();
};

@ -16,6 +16,9 @@ export interface Props {
builtInRoles?: { [key: string]: Role[] };
disabled?: boolean;
builtinRolesDisabled?: boolean;
updateDisabled?: boolean;
onApplyRoles?: (newRoles: Role[], userId: number, orgId: number | undefined) => void;
pendingRoles?: Role[];
}
export const UserRolePicker: FC<Props> = ({
@ -27,9 +30,17 @@ export const UserRolePicker: FC<Props> = ({
builtInRoles,
disabled,
builtinRolesDisabled,
updateDisabled,
onApplyRoles,
pendingRoles,
}) => {
const [{ loading, value: appliedRoles = [] }, getUserRoles] = useAsyncFn(async () => {
try {
if (updateDisabled) {
if (pendingRoles?.length! > 0) {
return pendingRoles;
}
}
if (contextSrv.hasPermission(AccessControlAction.ActionUserRolesList)) {
return await fetchUserRoles(userId, orgId);
}
@ -38,29 +49,39 @@ export const UserRolePicker: FC<Props> = ({
console.error('Error loading options');
}
return [];
}, [orgId, userId]);
}, [orgId, userId, pendingRoles]);
useEffect(() => {
getUserRoles();
}, [orgId, userId, getUserRoles]);
// only load roles when there is an Org selected
if (orgId) {
getUserRoles();
}
}, [orgId, getUserRoles, pendingRoles]);
const onRolesChange = async (roles: string[]) => {
await updateUserRoles(roles, userId, orgId);
await getUserRoles();
const onRolesChange = async (roles: Role[]) => {
if (!updateDisabled) {
await updateUserRoles(roles, userId, orgId);
await getUserRoles();
} else {
if (onApplyRoles) {
onApplyRoles(roles, userId, orgId);
}
}
};
return (
<RolePicker
appliedRoles={appliedRoles}
builtInRole={builtInRole}
onRolesChange={onRolesChange}
onBuiltinRoleChange={onBuiltinRoleChange}
roleOptions={roleOptions}
appliedRoles={appliedRoles}
builtInRoles={builtInRoles}
isLoading={loading}
disabled={disabled}
builtinRolesDisabled={builtinRolesDisabled}
showBuiltInRole
updateDisabled={updateDisabled || false}
/>
);
};

@ -38,11 +38,12 @@ export const fetchUserRoles = async (userId: number, orgId?: number): Promise<Ro
}
};
export const updateUserRoles = (roleUids: string[], userId: number, orgId?: number) => {
export const updateUserRoles = (roles: Role[], userId: number, orgId?: number) => {
let userRolesUrl = `/api/access-control/users/${userId}/roles`;
if (orgId) {
userRolesUrl += `?targetOrgId=${orgId}`;
}
const roleUids = roles.flatMap((x) => x.uid);
return getBackendSrv().put(userRolesUrl, {
orgId,
roleUids,
@ -66,11 +67,13 @@ export const fetchTeamRoles = async (teamId: number, orgId?: number): Promise<Ro
}
};
export const updateTeamRoles = (roleUids: string[], teamId: number, orgId?: number) => {
export const updateTeamRoles = (roles: Role[], teamId: number, orgId?: number) => {
let teamRolesUrl = `/api/access-control/teams/${teamId}/roles`;
if (orgId) {
teamRolesUrl += `?targetOrgId=${orgId}`;
}
const roleUids = roles.flatMap((x) => x.uid);
return getBackendSrv().put(teamRolesUrl, {
orgId,
roleUids,

@ -4,7 +4,7 @@ import { useAsyncFn } from 'react-use';
import { SelectableValue } from '@grafana/data';
import { AsyncSelect } from '@grafana/ui';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { Organization } from 'app/types';
import { Organization, UserOrg } from 'app/types';
export type OrgSelectItem = SelectableValue<Organization>;
@ -13,9 +13,10 @@ export interface Props {
className?: string;
inputId?: string;
autoFocus?: boolean;
excludeOrgs?: UserOrg[];
}
export function OrgPicker({ onSelected, className, inputId, autoFocus }: Props) {
export function OrgPicker({ onSelected, className, inputId, autoFocus, excludeOrgs }: Props) {
// For whatever reason the autoFocus prop doesn't seem to work
// with AsyncSelect, hence this workaround. Maybe fixed in a later version?
useEffect(() => {
@ -26,7 +27,16 @@ export function OrgPicker({ onSelected, className, inputId, autoFocus }: Props)
const [orgOptionsState, getOrgOptions] = useAsyncFn(async () => {
const orgs: Organization[] = await getBackendSrv().get('/api/orgs');
return orgs.map((org) => ({ value: { id: org.id, name: org.name }, label: org.name }));
const allOrgs = orgs.map((org) => ({ value: { id: org.id, name: org.name }, label: org.name }));
if (excludeOrgs) {
let idArray = excludeOrgs.map((anOrg) => anOrg.orgId);
const filteredOrgs = allOrgs.filter((item) => {
return !idArray.includes(item.value.id);
});
return filteredOrgs;
} else {
return allOrgs;
}
});
return (

@ -17,10 +17,10 @@ import {
withTheme,
} from '@grafana/ui';
import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
import { fetchRoleOptions } from 'app/core/components/RolePicker/api';
import { fetchRoleOptions, updateUserRoles } from 'app/core/components/RolePicker/api';
import { OrgPicker, OrgSelectItem } from 'app/core/components/Select/OrgPicker';
import { contextSrv } from 'app/core/core';
import { AccessControlAction, Organization, OrgRole, UserDTO, UserOrg } from 'app/types';
import { AccessControlAction, Organization, OrgRole, Role, UserDTO, UserOrg } from 'app/types';
import { OrgRolePicker } from './OrgRolePicker';
@ -88,7 +88,13 @@ export class UserOrgs extends PureComponent<Props, State> {
</Button>
)}
</div>
<AddToOrgModal isOpen={showAddOrgModal} onOrgAdd={onOrgAdd} onDismiss={this.dismissOrgAddModal} />
<AddToOrgModal
user={user}
userOrgs={orgs}
isOpen={showAddOrgModal}
onOrgAdd={onOrgAdd}
onDismiss={this.dismissOrgAddModal}
/>
</div>
</>
);
@ -155,8 +161,9 @@ class UnThemedOrgRow extends PureComponent<OrgRowProps> {
}
}
onOrgRemove = () => {
const { org } = this.props;
onOrgRemove = async () => {
const { org, user } = this.props;
user && (await updateUserRoles([], user.id, org.orgId));
this.props.onOrgRemove(org.orgId);
};
@ -272,7 +279,8 @@ const getAddToOrgModalStyles = stylesFactory(() => ({
interface AddToOrgModalProps {
isOpen: boolean;
user?: UserDTO;
userOrgs: UserOrg[];
onOrgAdd(orgId: number, role: string): void;
onDismiss?(): void;
@ -281,16 +289,32 @@ interface AddToOrgModalProps {
interface AddToOrgModalState {
selectedOrg: Organization | null;
role: OrgRole;
roleOptions: Role[];
pendingOrgId: number | null;
pendingUserId: number | null;
pendingRoles: Role[];
}
export class AddToOrgModal extends PureComponent<AddToOrgModalProps, AddToOrgModalState> {
state: AddToOrgModalState = {
selectedOrg: null,
role: OrgRole.Admin,
role: OrgRole.Viewer,
roleOptions: [],
pendingOrgId: null,
pendingUserId: null,
pendingRoles: [],
};
onOrgSelect = (org: OrgSelectItem) => {
this.setState({ selectedOrg: org.value! });
const userOrg = this.props.userOrgs.find((userOrg) => userOrg.orgId === org.value?.id);
this.setState({ selectedOrg: org.value!, role: userOrg?.role || OrgRole.Viewer });
if (contextSrv.licensedAccessControlEnabled()) {
if (contextSrv.hasPermission(AccessControlAction.ActionRolesList)) {
fetchRoleOptions(org.value?.id)
.then((roles) => this.setState({ roleOptions: roles }))
.catch((e) => console.error(e));
}
}
};
onOrgRoleChange = (newRole: OrgRole) => {
@ -299,20 +323,48 @@ export class AddToOrgModal extends PureComponent<AddToOrgModalProps, AddToOrgMod
});
};
onAddUserToOrg = () => {
onAddUserToOrg = async () => {
const { selectedOrg, role } = this.state;
this.props.onOrgAdd(selectedOrg!.id, role);
// add the stored userRoles also
if (contextSrv.licensedAccessControlEnabled()) {
if (contextSrv.hasPermission(AccessControlAction.OrgUsersRoleUpdate)) {
if (this.state.pendingUserId) {
await updateUserRoles(this.state.pendingRoles, this.state.pendingUserId!, this.state.pendingOrgId!);
// clear pending state
this.state.pendingOrgId = null;
this.state.pendingRoles = [];
this.state.pendingUserId = null;
}
}
}
};
onCancel = () => {
// clear selectedOrg when modal is canceled
this.setState({
selectedOrg: null,
pendingRoles: [],
pendingOrgId: null,
pendingUserId: null,
});
if (this.props.onDismiss) {
this.props.onDismiss();
}
};
onRoleUpdate = async (roles: Role[], userId: number, orgId: number | undefined) => {
// keep the new role assignments for user
this.setState({
pendingRoles: roles,
pendingOrgId: orgId!,
pendingUserId: userId,
});
};
render() {
const { isOpen } = this.props;
const { role } = this.state;
const { isOpen, user, userOrgs } = this.props;
const { role, roleOptions, selectedOrg } = this.state;
const styles = getAddToOrgModalStyles();
return (
<Modal
@ -323,17 +375,31 @@ export class AddToOrgModal extends PureComponent<AddToOrgModalProps, AddToOrgMod
onDismiss={this.onCancel}
>
<Field label="Organization">
<OrgPicker inputId="new-org-input" onSelected={this.onOrgSelect} autoFocus />
<OrgPicker inputId="new-org-input" onSelected={this.onOrgSelect} excludeOrgs={userOrgs} autoFocus />
</Field>
<Field label="Role">
<OrgRolePicker inputId="new-org-role-input" value={role} onChange={this.onOrgRoleChange} />
<Field label="Role" disabled={selectedOrg === null}>
{contextSrv.accessControlEnabled() ? (
<UserRolePicker
userId={user?.id || 0}
orgId={selectedOrg?.id}
builtInRole={role}
onBuiltinRoleChange={this.onOrgRoleChange}
builtinRolesDisabled={false}
roleOptions={roleOptions}
updateDisabled={true}
onApplyRoles={this.onRoleUpdate}
pendingRoles={this.state.pendingRoles}
/>
) : (
<OrgRolePicker inputId="new-org-role-input" value={role} onChange={this.onOrgRoleChange} />
)}
</Field>
<Modal.ButtonRow>
<HorizontalGroup spacing="md" justify="center">
<Button variant="secondary" fill="outline" onClick={this.onCancel}>
Cancel
</Button>
<Button variant="primary" onClick={this.onAddUserToOrg}>
<Button variant="primary" disabled={selectedOrg === null} onClick={this.onAddUserToOrg}>
Add to organization
</Button>
</HorizontalGroup>

@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { connect } from 'react-redux';
import { useHistory } from 'react-router-dom';
@ -6,6 +6,10 @@ import { NavModel } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { Form, Button, Input, Field } from '@grafana/ui';
import Page from 'app/core/components/Page/Page';
import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
import { fetchBuiltinRoles, fetchRoleOptions, updateUserRoles } from 'app/core/components/RolePicker/api';
import { contextSrv } from 'app/core/core';
import { AccessControlAction, OrgRole, Role, ServiceAccountCreateApiResponse, ServiceAccountDTO } from 'app/types';
import { getNavModel } from '../../core/selectors/navModel';
import { StoreState } from '../../types';
@ -13,23 +17,89 @@ import { StoreState } from '../../types';
interface ServiceAccountCreatePageProps {
navModel: NavModel;
}
interface ServiceAccountDTO {
name: string;
}
const createServiceAccount = async (sa: ServiceAccountDTO) => getBackendSrv().post('/api/serviceaccounts/', sa);
const updateServiceAccount = async (id: number, sa: ServiceAccountDTO) =>
getBackendSrv().patch(`/api/serviceaccounts/${id}`, sa);
const ServiceAccountCreatePage: React.FC<ServiceAccountCreatePageProps> = ({ navModel }) => {
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
const [builtinRoles, setBuiltinRoles] = useState<{ [key: string]: Role[] }>({});
const [pendingRoles, setPendingRoles] = useState<Role[]>([]);
const currentOrgId = contextSrv.user.orgId;
const [serviceAccount, setServiceAccount] = useState<ServiceAccountDTO>({
id: 0,
orgId: contextSrv.user.orgId,
role: OrgRole.Viewer,
tokens: 0,
name: '',
login: '',
isDisabled: false,
createdAt: '',
teams: [],
});
useEffect(() => {
async function fetchOptions() {
try {
if (contextSrv.hasPermission(AccessControlAction.ActionRolesList)) {
let options = await fetchRoleOptions(currentOrgId);
setRoleOptions(options);
}
if (contextSrv.hasPermission(AccessControlAction.ActionBuiltinRolesList)) {
const builtInRoles = await fetchBuiltinRoles(currentOrgId);
setBuiltinRoles(builtInRoles);
}
} catch (e) {
console.error('Error loading options', e);
}
}
if (contextSrv.licensedAccessControlEnabled()) {
fetchOptions();
}
}, [currentOrgId]);
const history = useHistory();
const onSubmit = useCallback(
async (data: ServiceAccountDTO) => {
await createServiceAccount(data);
data.role = serviceAccount.role;
const response = await createServiceAccount(data);
try {
const newAccount: ServiceAccountCreateApiResponse = {
avatarUrl: response.avatarUrl,
id: response.id,
isDisabled: response.isDisabled,
login: response.login,
name: response.name,
orgId: response.orgId,
role: response.role,
tokens: response.tokens,
};
await updateServiceAccount(response.id, data);
await updateUserRoles(pendingRoles, newAccount.id, newAccount.orgId);
} catch (e) {
console.error(e);
}
history.push('/org/serviceaccounts/');
},
[history]
[history, serviceAccount.role, pendingRoles]
);
const onRoleChange = (role: OrgRole) => {
setServiceAccount({
...serviceAccount,
role: role,
});
};
const onPendingRolesUpdate = (roles: Role[], userId: number, orgId: number | undefined) => {
// keep the new role assignments for user
setPendingRoles(roles);
};
return (
<Page navModel={navModel}>
<Page.Contents>
@ -46,6 +116,22 @@ const ServiceAccountCreatePage: React.FC<ServiceAccountCreatePageProps> = ({ nav
>
<Input id="display-name-input" {...register('name', { required: true })} />
</Field>
{contextSrv.accessControlEnabled() && (
<Field label="Role">
<UserRolePicker
userId={serviceAccount.id || 0}
orgId={serviceAccount.orgId}
builtInRole={serviceAccount.role}
builtInRoles={builtinRoles}
onBuiltinRoleChange={(newRole) => onRoleChange(newRole)}
builtinRolesDisabled={false}
roleOptions={roleOptions}
updateDisabled={true}
onApplyRoles={onPendingRolesUpdate}
pendingRoles={pendingRoles}
/>
</Field>
)}
<Button type="submit">Create</Button>
</>
);

@ -66,16 +66,23 @@ const ServiceAccountsListPage = ({
const styles = useStyles2(getStyles);
useEffect(() => {
fetchServiceAccounts();
if (contextSrv.licensedAccessControlEnabled()) {
fetchACOptions();
}
const fetchData = async () => {
await fetchServiceAccounts();
if (contextSrv.licensedAccessControlEnabled()) {
await fetchACOptions();
}
};
fetchData();
}, [fetchServiceAccounts, fetchACOptions]);
const onRoleChange = async (role: OrgRole, serviceAccount: ServiceAccountDTO) => {
const updatedServiceAccount = { ...serviceAccount, role: role };
await updateServiceAccount(updatedServiceAccount);
// need to refetch to display the new value in the list
await fetchServiceAccounts();
if (contextSrv.licensedAccessControlEnabled()) {
fetchACOptions();
}
};
return (

@ -38,6 +38,17 @@ export interface ServiceAccountDTO extends WithAccessControlMetadata {
role: OrgRole;
}
export interface ServiceAccountCreateApiResponse {
avatarUrl?: string;
id: number;
isDisabled: boolean;
login: string;
name: string;
orgId: number;
role: OrgRole;
tokens: number;
}
export interface ServiceAccountProfileState {
serviceAccount: ServiceAccountDTO;
isLoading: boolean;

Loading…
Cancel
Save