mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-31 00:16:47 +01:00
bb026c0ba1
## About the changes Implements custom root roles, encompassing a lot of different areas of the project, and slightly refactoring the current roles logic. It includes quite a clean up. This feature itself is behind a flag: `customRootRoles` This feature covers root roles in: - Users; - Service Accounts; - Groups; Apologies in advance. I may have gotten a bit carried away 🙈 ### Roles We now have a new admin tab called "Roles" where we can see all root roles and manage custom ones. We are not allowed to edit or remove *predefined* roles. ![image](https://github.com/Unleash/unleash/assets/14320932/1ad8695c-8c3f-440d-ac32-39746720d588) This meant slightly pushing away the existing roles to `project-roles` instead. One idea we want to explore in the future is to unify both types of roles in the UI instead of having 2 separate tabs. This includes modernizing project roles to fit more into our current design and decisions. Hovering the permissions cell expands detailed information about the role: ![image](https://github.com/Unleash/unleash/assets/14320932/81c4aae7-8b4d-4cb4-92d1-8f1bc3ef1f2a) ### Create and edit role Here's how the role form looks like (create / edit): ![image](https://github.com/Unleash/unleash/assets/14320932/85baec29-bb10-48c5-a207-b3e9a8de838a) Here I categorized permissions so it's easier to visualize and manage from a UX perspective. I'm using the same endpoint as before. I tried to unify the logic and get rid of the `projectRole` specific hooks. What distinguishes custom root roles from custom project roles is the extra `root-custom` type we see on the payload. By default we assume `custom` (custom project role) instead, which should help in terms of backwards compatibility. ### Delete role When we delete a custom role we try to help the end user make an informed decision by listing all the entities which currently use this custom root role: ![image](https://github.com/Unleash/unleash/assets/14320932/352ed529-76be-47a8-88da-5e924fb191d4) ~~As mentioned in the screenshot, when deleting a custom role, we demote all entities associated with it to the predefined `Viewer` role.~~ **EDIT**: Apparently we currently block this from the API (access-service deleteRole) with a message: ![image](https://github.com/Unleash/unleash/assets/14320932/82a8e50f-8dc5-4c18-a2ba-54e2ae91b91c) What should the correct behavior be? ### Role selector I added a new easy-to-use role selector component that is present in: - Users ![image](https://github.com/Unleash/unleash/assets/14320932/76953139-7fb6-437e-b3fa-ace1d9187674) - Service Accounts ![image](https://github.com/Unleash/unleash/assets/14320932/2b80bd55-9abb-4883-b715-15650ae752ea) - Groups ![image](https://github.com/Unleash/unleash/assets/14320932/ab438f7c-2245-4779-b157-2da1689fe402) ### Role description I also added a new role description component that you can see below the dropdown in the selector component, but it's also used to better describe each role in the respective tables: ![image](https://github.com/Unleash/unleash/assets/14320932/a3eecac1-2a34-4500-a68c-e3f62ebfa782) I'm not listing all the permissions of predefined roles. Those simply show the description in the tooltip: ![image](https://github.com/Unleash/unleash/assets/14320932/7e5b2948-45f0-4472-8311-bf533409ba6c) ### Role badge Groups is a bit different, since it uses a list of cards, so I added yet another component - Role badge: ![image](https://github.com/Unleash/unleash/assets/14320932/1d62c3db-072a-4c97-b86f-1d8ebdd3523e) I'm using this same component on the profile tab: ![image](https://github.com/Unleash/unleash/assets/14320932/214272db-a828-444e-8846-4f39b9456bc6) ## Discussion points - Are we being defensive enough with the use of the flag? Should we cover more? - Are we breaking backwards compatibility in any way? - What should we do when removing a role? Block or demote? - Maybe some existing permission-related issues will surface with this change: Are we being specific enough with our permissions? A lot of places are simply checking for `ADMIN`; - We may want to get rid of the API roles coupling we have with the users and SAs and instead use the new hooks (e.g. `useRoles`) explicitly; - We should update the docs; - Maybe we could allow the user to add a custom role directly from the role selector component; --------- Co-authored-by: Gastón Fournier <gaston@getunleash.io>
502 lines
14 KiB
TypeScript
502 lines
14 KiB
TypeScript
import { EventEmitter } from 'events';
|
|
import metricsHelper from '../util/metrics-helper';
|
|
import { DB_TIME } from '../metric-events';
|
|
import { Logger } from '../logger';
|
|
import {
|
|
IAccessInfo,
|
|
IAccessStore,
|
|
IRole,
|
|
IRoleWithProject,
|
|
IUserPermission,
|
|
IUserRole,
|
|
} from '../types/stores/access-store';
|
|
import { IPermission } from '../types/model';
|
|
import NotFoundError from '../error/notfound-error';
|
|
import {
|
|
ENVIRONMENT_PERMISSION_TYPE,
|
|
ROOT_PERMISSION_TYPE,
|
|
} from '../util/constants';
|
|
import { Db } from './db';
|
|
|
|
const T = {
|
|
ROLE_USER: 'role_user',
|
|
ROLES: 'roles',
|
|
GROUPS: 'groups',
|
|
GROUP_ROLE: 'group_role',
|
|
GROUP_USER: 'group_user',
|
|
ROLE_PERMISSION: 'role_permission',
|
|
PERMISSIONS: 'permissions',
|
|
PERMISSION_TYPES: 'permission_types',
|
|
CHANGE_REQUEST_SETTINGS: 'change_request_settings',
|
|
PERSONAL_ACCESS_TOKENS: 'personal_access_tokens',
|
|
PUBLIC_SIGNUP_TOKENS_USER: 'public_signup_tokens_user',
|
|
};
|
|
|
|
interface IPermissionRow {
|
|
id: number;
|
|
permission: string;
|
|
display_name: string;
|
|
environment?: string;
|
|
type: string;
|
|
project?: string;
|
|
role_id: number;
|
|
}
|
|
|
|
export class AccessStore implements IAccessStore {
|
|
private logger: Logger;
|
|
|
|
private timer: Function;
|
|
|
|
private db: Db;
|
|
|
|
constructor(db: Db, eventBus: EventEmitter, getLogger: Function) {
|
|
this.db = db;
|
|
this.logger = getLogger('access-store.ts');
|
|
this.timer = (action: string) =>
|
|
metricsHelper.wrapTimer(eventBus, DB_TIME, {
|
|
store: 'access-store',
|
|
action,
|
|
});
|
|
}
|
|
|
|
async delete(key: number): Promise<void> {
|
|
await this.db(T.ROLES).where({ id: key }).del();
|
|
}
|
|
|
|
async deleteAll(): Promise<void> {
|
|
await this.db(T.ROLES).del();
|
|
}
|
|
|
|
destroy(): void {}
|
|
|
|
async exists(key: number): Promise<boolean> {
|
|
const result = await this.db.raw(
|
|
`SELECT EXISTS(SELECT 1 FROM ${T.ROLES} WHERE id = ?) AS present`,
|
|
[key],
|
|
);
|
|
const { present } = result.rows[0];
|
|
return present;
|
|
}
|
|
|
|
async get(key: number): Promise<IRole> {
|
|
const role = await this.db
|
|
.select(['id', 'name', 'type', 'description'])
|
|
.where('id', key)
|
|
.first()
|
|
.from<IRole>(T.ROLES);
|
|
|
|
if (!role) {
|
|
throw new NotFoundError(`Could not find role with id: ${key}`);
|
|
}
|
|
|
|
return role;
|
|
}
|
|
|
|
async getAll(): Promise<IRole[]> {
|
|
return Promise.resolve([]);
|
|
}
|
|
|
|
async getAvailablePermissions(): Promise<IPermission[]> {
|
|
const rows = await this.db
|
|
.select(['id', 'permission', 'type', 'display_name'])
|
|
.where('type', 'project')
|
|
.orWhere('type', 'environment')
|
|
.orWhere('type', 'root')
|
|
.from(`${T.PERMISSIONS} as p`);
|
|
return rows.map(this.mapPermission);
|
|
}
|
|
|
|
mapPermission(permission: IPermissionRow): IPermission {
|
|
return {
|
|
id: permission.id,
|
|
name: permission.permission,
|
|
displayName: permission.display_name,
|
|
type: permission.type,
|
|
};
|
|
}
|
|
|
|
async getPermissionsForUser(userId: number): Promise<IUserPermission[]> {
|
|
const stopTimer = this.timer('getPermissionsForUser');
|
|
let userPermissionQuery = this.db
|
|
.select(
|
|
'project',
|
|
'permission',
|
|
'environment',
|
|
'type',
|
|
'ur.role_id',
|
|
)
|
|
.from<IPermissionRow>(`${T.ROLE_PERMISSION} AS rp`)
|
|
.join(`${T.ROLE_USER} AS ur`, 'ur.role_id', 'rp.role_id')
|
|
.join(`${T.PERMISSIONS} AS p`, 'p.id', 'rp.permission_id')
|
|
.where('ur.user_id', '=', userId);
|
|
|
|
userPermissionQuery = userPermissionQuery.union((db) => {
|
|
db.select(
|
|
'project',
|
|
'permission',
|
|
'environment',
|
|
'p.type',
|
|
'gr.role_id',
|
|
)
|
|
.from<IPermissionRow>(`${T.GROUP_USER} AS gu`)
|
|
.join(`${T.GROUPS} AS g`, 'g.id', 'gu.group_id')
|
|
.join(`${T.GROUP_ROLE} AS gr`, 'gu.group_id', 'gr.group_id')
|
|
.join(`${T.ROLE_PERMISSION} AS rp`, 'rp.role_id', 'gr.role_id')
|
|
.join(`${T.PERMISSIONS} AS p`, 'p.id', 'rp.permission_id')
|
|
.whereNull('g.root_role_id')
|
|
.andWhere('gu.user_id', '=', userId);
|
|
});
|
|
|
|
userPermissionQuery = userPermissionQuery.union((db) => {
|
|
db.select(
|
|
this.db.raw("'default' as project"),
|
|
'permission',
|
|
'environment',
|
|
'p.type',
|
|
'g.root_role_id as role_id',
|
|
)
|
|
.from<IPermissionRow>(`${T.GROUP_USER} as gu`)
|
|
.join(`${T.GROUPS} AS g`, 'g.id', 'gu.group_id')
|
|
.join(
|
|
`${T.ROLE_PERMISSION} as rp`,
|
|
'rp.role_id',
|
|
'g.root_role_id',
|
|
)
|
|
.join(`${T.PERMISSIONS} as p`, 'p.id', 'rp.permission_id')
|
|
.whereNotNull('g.root_role_id')
|
|
.andWhere('gu.user_id', '=', userId);
|
|
});
|
|
|
|
const rows = await userPermissionQuery;
|
|
stopTimer();
|
|
return rows.map(this.mapUserPermission);
|
|
}
|
|
|
|
mapUserPermission(row: IPermissionRow): IUserPermission {
|
|
let project: string | undefined = undefined;
|
|
// Since the editor should have access to the default project,
|
|
// we map the project to the project and environment specific
|
|
// permissions that are connected to the editor role.
|
|
if (row.type !== ROOT_PERMISSION_TYPE) {
|
|
project = row.project;
|
|
}
|
|
|
|
const environment =
|
|
row.type === ENVIRONMENT_PERMISSION_TYPE
|
|
? row.environment
|
|
: undefined;
|
|
|
|
return {
|
|
project,
|
|
environment,
|
|
permission: row.permission,
|
|
};
|
|
}
|
|
|
|
async getPermissionsForRole(roleId: number): Promise<IPermission[]> {
|
|
const stopTimer = this.timer('getPermissionsForRole');
|
|
const rows = await this.db
|
|
.select(
|
|
'p.id',
|
|
'p.permission',
|
|
'rp.environment',
|
|
'p.display_name',
|
|
'p.type',
|
|
)
|
|
.from<IPermission>(`${T.ROLE_PERMISSION} as rp`)
|
|
.join(`${T.PERMISSIONS} as p`, 'p.id', 'rp.permission_id')
|
|
.where('rp.role_id', '=', roleId);
|
|
stopTimer();
|
|
return rows.map((permission) => {
|
|
return {
|
|
id: permission.id,
|
|
name: permission.permission,
|
|
environment: permission.environment,
|
|
displayName: permission.display_name,
|
|
type: permission.type,
|
|
};
|
|
});
|
|
}
|
|
|
|
async addEnvironmentPermissionsToRole(
|
|
role_id: number,
|
|
permissions: IPermission[],
|
|
): Promise<void> {
|
|
const rows = permissions.map((permission) => {
|
|
return {
|
|
role_id,
|
|
permission_id: permission.id,
|
|
environment: permission.environment,
|
|
};
|
|
});
|
|
await this.db.batchInsert(T.ROLE_PERMISSION, rows);
|
|
}
|
|
|
|
async unlinkUserRoles(userId: number): Promise<void> {
|
|
return this.db(T.ROLE_USER)
|
|
.where({
|
|
user_id: userId,
|
|
})
|
|
.delete();
|
|
}
|
|
|
|
async unlinkUserGroups(userId: number): Promise<void> {
|
|
return this.db(T.GROUP_USER)
|
|
.where({
|
|
user_id: userId,
|
|
})
|
|
.delete();
|
|
}
|
|
|
|
async clearUserPersonalAccessTokens(userId: number): Promise<void> {
|
|
return this.db(T.PERSONAL_ACCESS_TOKENS)
|
|
.where({
|
|
user_id: userId,
|
|
})
|
|
.delete();
|
|
}
|
|
|
|
async clearPublicSignupUserTokens(userId: number): Promise<void> {
|
|
return this.db(T.PUBLIC_SIGNUP_TOKENS_USER)
|
|
.where({
|
|
user_id: userId,
|
|
})
|
|
.delete();
|
|
}
|
|
|
|
async getProjectUsersForRole(
|
|
roleId: number,
|
|
projectId?: string,
|
|
): Promise<IUserRole[]> {
|
|
const rows = await this.db
|
|
.select(['user_id', 'ru.created_at'])
|
|
.from<IRole>(`${T.ROLE_USER} AS ru`)
|
|
.join(`${T.ROLES} as r`, 'ru.role_id', 'id')
|
|
.where('r.id', roleId)
|
|
.andWhere('ru.project', projectId);
|
|
return rows.map((r) => ({
|
|
userId: r.user_id,
|
|
addedAt: r.created_at,
|
|
}));
|
|
}
|
|
|
|
async getRolesForUserId(userId: number): Promise<IRoleWithProject[]> {
|
|
return this.db
|
|
.select(['id', 'name', 'type', 'project', 'description'])
|
|
.from<IRole[]>(T.ROLES)
|
|
.innerJoin(`${T.ROLE_USER} as ru`, 'ru.role_id', 'id')
|
|
.where('ru.user_id', '=', userId);
|
|
}
|
|
|
|
async getUserIdsForRole(roleId: number): Promise<number[]> {
|
|
const rows = await this.db
|
|
.select(['user_id'])
|
|
.from<IRole>(T.ROLE_USER)
|
|
.where('role_id', roleId);
|
|
return rows.map((r) => r.user_id);
|
|
}
|
|
|
|
async addUserToRole(
|
|
userId: number,
|
|
roleId: number,
|
|
projectId?: string,
|
|
): Promise<void> {
|
|
return this.db(T.ROLE_USER).insert({
|
|
user_id: userId,
|
|
role_id: roleId,
|
|
project: projectId,
|
|
});
|
|
}
|
|
|
|
async removeUserFromRole(
|
|
userId: number,
|
|
roleId: number,
|
|
projectId?: string,
|
|
): Promise<void> {
|
|
return this.db(T.ROLE_USER)
|
|
.where({
|
|
user_id: userId,
|
|
role_id: roleId,
|
|
project: projectId,
|
|
})
|
|
.delete();
|
|
}
|
|
|
|
async addGroupToRole(
|
|
groupId: number,
|
|
roleId: number,
|
|
createdBy: string,
|
|
projectId?: string,
|
|
): Promise<void> {
|
|
return this.db(T.GROUP_ROLE).insert({
|
|
group_id: groupId,
|
|
role_id: roleId,
|
|
project: projectId,
|
|
created_by: createdBy,
|
|
});
|
|
}
|
|
|
|
async removeGroupFromRole(
|
|
groupId: number,
|
|
roleId: number,
|
|
projectId?: string,
|
|
): Promise<void> {
|
|
return this.db(T.GROUP_ROLE)
|
|
.where({
|
|
group_id: groupId,
|
|
role_id: roleId,
|
|
project: projectId,
|
|
})
|
|
.delete();
|
|
}
|
|
|
|
async updateUserProjectRole(
|
|
userId: number,
|
|
roleId: number,
|
|
projectId: string,
|
|
): Promise<void> {
|
|
return this.db(T.ROLE_USER)
|
|
.where({
|
|
user_id: userId,
|
|
project: projectId,
|
|
})
|
|
.whereNotIn(
|
|
'role_id',
|
|
this.db(T.ROLES).select('id as role_id').where('type', 'root'),
|
|
)
|
|
.update('role_id', roleId);
|
|
}
|
|
|
|
updateGroupProjectRole(
|
|
groupId: number,
|
|
roleId: number,
|
|
projectId: string,
|
|
): Promise<void> {
|
|
return this.db(T.GROUP_ROLE)
|
|
.where({
|
|
group_id: groupId,
|
|
project: projectId,
|
|
})
|
|
.whereNotIn(
|
|
'role_id',
|
|
this.db(T.ROLES).select('id as role_id').where('type', 'root'),
|
|
)
|
|
.update('role_id', roleId);
|
|
}
|
|
|
|
async addAccessToProject(
|
|
users: IAccessInfo[],
|
|
groups: IAccessInfo[],
|
|
projectId: string,
|
|
roleId: number,
|
|
createdBy: string,
|
|
): Promise<void> {
|
|
const userRows = users.map((user) => {
|
|
return {
|
|
user_id: user.id,
|
|
project: projectId,
|
|
role_id: roleId,
|
|
};
|
|
});
|
|
|
|
const groupRows = groups.map((group) => {
|
|
return {
|
|
group_id: group.id,
|
|
project: projectId,
|
|
role_id: roleId,
|
|
created_by: createdBy,
|
|
};
|
|
});
|
|
|
|
await this.db.transaction(async (tx) => {
|
|
if (userRows.length > 0) {
|
|
await tx(T.ROLE_USER)
|
|
.insert(userRows)
|
|
.onConflict(['project', 'role_id', 'user_id'])
|
|
.merge();
|
|
}
|
|
if (groupRows.length > 0) {
|
|
await tx(T.GROUP_ROLE)
|
|
.insert(groupRows)
|
|
.onConflict(['project', 'role_id', 'group_id'])
|
|
.merge();
|
|
}
|
|
});
|
|
}
|
|
|
|
async removeRolesOfTypeForUser(
|
|
userId: number,
|
|
roleTypes: string[],
|
|
): Promise<void> {
|
|
const rolesToRemove = this.db(T.ROLES)
|
|
.select('id')
|
|
.whereIn('type', roleTypes);
|
|
|
|
return this.db(T.ROLE_USER)
|
|
.where({ user_id: userId })
|
|
.whereIn('role_id', rolesToRemove)
|
|
.delete();
|
|
}
|
|
|
|
async addPermissionsToRole(
|
|
role_id: number,
|
|
permissions: string[],
|
|
environment?: string,
|
|
): Promise<void> {
|
|
const rows = await this.db
|
|
.select('id as permissionId')
|
|
.from<number>(T.PERMISSIONS)
|
|
.whereIn('permission', permissions);
|
|
|
|
const newRoles = rows.map((row) => ({
|
|
role_id,
|
|
environment,
|
|
permission_id: row.permissionId,
|
|
}));
|
|
|
|
return this.db.batchInsert(T.ROLE_PERMISSION, newRoles);
|
|
}
|
|
|
|
async removePermissionFromRole(
|
|
role_id: number,
|
|
permission: string,
|
|
environment?: string,
|
|
): Promise<void> {
|
|
const rows = await this.db
|
|
.select('id as permissionId')
|
|
.from<number>(T.PERMISSIONS)
|
|
.where('permission', permission);
|
|
|
|
const permissionId = rows[0].permissionId;
|
|
|
|
return this.db(T.ROLE_PERMISSION)
|
|
.where({
|
|
role_id,
|
|
permission_id: permissionId,
|
|
environment,
|
|
})
|
|
.delete();
|
|
}
|
|
|
|
async wipePermissionsFromRole(role_id: number): Promise<void> {
|
|
return this.db(T.ROLE_PERMISSION)
|
|
.where({
|
|
role_id,
|
|
})
|
|
.delete();
|
|
}
|
|
|
|
async cloneEnvironmentPermissions(
|
|
sourceEnvironment: string,
|
|
destinationEnvironment: string,
|
|
): Promise<void> {
|
|
return this.db.raw(
|
|
`insert into role_permission
|
|
(role_id, permission_id, environment)
|
|
(select role_id, permission_id, ?
|
|
from ${T.ROLE_PERMISSION} where environment = ?)`,
|
|
[destinationEnvironment, sourceEnvironment],
|
|
);
|
|
}
|
|
}
|