Skip to content
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

chore: improve access service #4689

Merged
merged 17 commits into from
Sep 19, 2023
Merged
Show file tree
Hide file tree
Changes from 10 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
112 changes: 112 additions & 0 deletions src/lib/db/access-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import dbInit from '../../test/e2e/helpers/database-init';
import getLogger from '../../test/fixtures/no-logger';
import { PermissionRef } from 'lib/services/access-service';
import { AccessStore } from './access-store';

let db;

beforeAll(async () => {
db = await dbInit('feature_strategy_store_serial', getLogger);
gastonfournier marked this conversation as resolved.
Show resolved Hide resolved
});

afterAll(async () => {
if (db) {
await db.destroy();
}
});

// Helper function to make the test cases more readable
const args = (permissions: PermissionRef[], expectations?: PermissionRef[]) => {
if (expectations) {
return [permissions, expectations];
} else {
return [permissions];
}
};

test('resolvePermissions returns empty list if undefined', async () => {
const access = db.stores.accessStore as AccessStore;
const result = await access.resolvePermissions(
undefined as unknown as PermissionRef[],
);
expect(result).toStrictEqual([]);
});

test('resolvePermissions returns empty list if empty list', async () => {
const access = db.stores.accessStore as AccessStore;
const result = await access.resolvePermissions(
undefined as unknown as PermissionRef[],
gastonfournier marked this conversation as resolved.
Show resolved Hide resolved
);
expect(result).toStrictEqual([]);
});

test.each([
args([{ id: 1 }]),
args([{ id: 4, environment: 'development' }]),
args([{ id: 4, name: 'should keep the id' }]),
args([
{ id: 1, environment: 'development' },
{ id: 2, name: 'ignore this name' },
]),
])(
'resolvePermissions with permission ids (%o) returns the list unmodified',
async (permissions) => {
const access = db.stores.accessStore as AccessStore;
const result = await access.resolvePermissions(permissions);
expect(result).toStrictEqual(permissions);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'd love to say: no query to the DB here

},
);

test.each([
args(
[{ name: 'CREATE_CONTEXT_FIELD' }],
[{ id: 18, name: 'CREATE_CONTEXT_FIELD' }],
),
args(
[{ name: 'CREATE_FEATURE', environment: 'development' }],
[{ id: 2, name: 'CREATE_FEATURE', environment: 'development' }],
),
args(
[
{ name: 'CREATE_CONTEXT_FIELD' },
{ name: 'CREATE_FEATURE', environment: 'development' },
],
[
{ id: 18, name: 'CREATE_CONTEXT_FIELD' },
{ id: 2, name: 'CREATE_FEATURE', environment: 'development' },
],
),
])(
'resolvePermissions with permission names (%o) will inject the ids',
async (permissions, expected) => {
const access = db.stores.accessStore as AccessStore;
const result = await access.resolvePermissions(permissions);
expect(result).toStrictEqual(expected);
},
);

test.each([
args(
[
{ name: 'CREATE_CONTEXT_FIELD' },
{ id: 3 },
{ name: 'CREATE_FEATURE', environment: 'development' },
{ id: 15, environment: 'development' },
{ name: 'UPDATE_FEATURE', environment: 'development' },
],
[
{ id: 18, name: 'CREATE_CONTEXT_FIELD' },
{ id: 3 },
{ id: 2, name: 'CREATE_FEATURE', environment: 'development' },
{ id: 15, environment: 'development' },
{ id: 7, name: 'UPDATE_FEATURE', environment: 'development' },
],
),
])(
'resolvePermissions mixed ids and names (%o) will inject the ids where they are missing',
async (permissions, expected) => {
const access = db.stores.accessStore as AccessStore;
const result = await access.resolvePermissions(permissions);
expect(result).toStrictEqual(expected);
},
);
95 changes: 85 additions & 10 deletions src/lib/db/access-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ import {
ROOT_PERMISSION_TYPE,
} from '../util/constants';
import { Db } from './db';
import { IdPermissionRef } from 'lib/services/access-service';
import {
IdPermissionRef,
NamePermissionRef,
PermissionRef,
} from 'lib/services/access-service';

const T = {
ROLE_USER: 'role_user',
Expand All @@ -46,13 +50,84 @@ interface IPermissionRow {
role_id: number;
}

type ResolvedPermission = {
id: number;
name?: string;
environment?: string;
};

export class AccessStore implements IAccessStore {
private logger: Logger;

private timer: Function;

private db: Db;

// for internal usage only

private isIdPermissionRef = (permission: PermissionRef): boolean => {
return (permission as IdPermissionRef).id !== undefined;
};

private permissionNamesToIds = async (
permissions: NamePermissionRef[],
): Promise<ResolvedPermission[]> => {
if (permissions === undefined || permissions.length === 0) {
return [];
}
const permissionNames = permissions
.filter((p) => p.name !== undefined)
.map((p) => p.name);

const rows = await this.db
.select('id', 'permission')
.from(T.PERMISSIONS)
.whereIn('permission', permissionNames);
gastonfournier marked this conversation as resolved.
Show resolved Hide resolved
Comment on lines +93 to +96
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same query as here: https://github.com/Unleash/unleash/pull/4689/files#diff-5cb4eca71e6a03ec3d123986d657368a3386f80e986dfef2e3feb0abb0673d87L706-L709 but now it can be used in all scenarios of adding permissions to a role


const rowByPermissionName = rows.reduce((acc, row) => {
acc[row.permission] = row;
return acc;
}, {} as Map<string, IPermissionRow>);

return permissions.map((permission) => ({
id: rowByPermissionName[permission.name].id,
...permission,
}));
};

resolvePermissions = async (
permissions: PermissionRef[],
): Promise<ResolvedPermission[]> => {
if (permissions === undefined || permissions.length === 0) {
return [];
}
// permissions without ids (just names)
const permissionsWithoutIds = permissions.filter(
(p) => !this.isIdPermissionRef(p),
) as NamePermissionRef[];
const idPermissionsFromNamed = await this.permissionNamesToIds(
permissionsWithoutIds,
);

if (permissionsWithoutIds.length === permissions.length) {
// all named permissions without ids
return idPermissionsFromNamed;
} else if (permissionsWithoutIds.length === 0) {
// all permissions have ids
return permissions as ResolvedPermission[];
}
// some permissions have ids, some don't (should not happen!)
return permissions.map((permission) => {
if (this.isIdPermissionRef(permission)) {
return permission as ResolvedPermission;
} else {
return idPermissionsFromNamed.find(
(p) => p.name === (permission as NamePermissionRef).name,
)!;
}
});
};

constructor(db: Db, eventBus: EventEmitter, getLogger: Function) {
this.db = db;
this.logger = getLogger('access-store.ts');
Expand Down Expand Up @@ -222,9 +297,11 @@ export class AccessStore implements IAccessStore {

async addEnvironmentPermissionsToRole(
role_id: number,
permissions: IdPermissionRef[],
permissions: PermissionRef[],
): Promise<void> {
const rows = permissions.map((permission) => {
const resolvedPermission = await this.resolvePermissions(permissions);

const rows = resolvedPermission.map((permission) => {
return {
role_id,
permission_id: permission.id,
Expand Down Expand Up @@ -700,18 +777,16 @@ export class AccessStore implements IAccessStore {

async addPermissionsToRole(
role_id: number,
permissions: string[],
permissions: PermissionRef[],
environment?: string,
): Promise<void> {
const rows = await this.db
.select('id as permissionId')
.from<number>(T.PERMISSIONS)
.whereIn('permission', permissions);
// no need to pass down the environment in this particular case because it'll be overriden
const permissionsWithIds = await this.resolvePermissions(permissions);

const newRoles = rows.map((row) => ({
const newRoles = permissionsWithIds.map((p) => ({
role_id,
environment,
permission_id: row.permissionId,
permission_id: p.id,
}));

return this.db.batchInsert(T.ROLE_PERMISSION, newRoles);
Expand Down
2 changes: 1 addition & 1 deletion src/lib/schema/role-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ test('role schema rejects a role with a broken permission list', async () => {
await roleSchema.validateAsync(role);
} catch (error) {
expect(error.details[0].message).toBe(
'"permissions[0].id" is required',
'"permissions[0]" must contain at least one of [id, name]',
);
}
});
Expand Down
4 changes: 3 additions & 1 deletion src/lib/schema/role-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import joi from 'joi';
export const permissionRoleSchema = joi
.object()
.keys({
id: joi.number().required(),
id: joi.number(),
name: joi.string(),
Comment on lines +6 to +7
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This validation now expects that either you provide the id or the name (or both)

environment: joi.string().optional().allow('').allow(null).default(''),
})
.or('id', 'name')
.options({ stripUnknown: true, allowUnknown: false, abortEarly: false });

export const roleSchema = joi
Expand Down
1 change: 1 addition & 0 deletions src/lib/services/access-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ test('should be able to validate and cleanup with additional properties', async
permissions: [
{
id: 1,
name: 'name',
environment: 'development',
},
],
Expand Down
14 changes: 10 additions & 4 deletions src/lib/services/access-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export interface IRoleValidation {
permissions?: PermissionRef[];
}

interface IRoleUpdate {
export interface IRoleUpdate {
id: number;
name: string;
description: string;
Expand Down Expand Up @@ -404,9 +404,13 @@ export class AccessService {
`ProjectId cannot be empty for permission=${permission}`,
);
}
// return this.store.addPermissionsToRole(roleId, [
// { name: permission, environment },
// ]);
// console.log('addPermissionToRole', roleId, permission, environment);
return this.store.addPermissionsToRole(
roleId,
[permission],
[{ name: permission }],
environment,
);
}
Expand Down Expand Up @@ -630,11 +634,13 @@ export class AccessService {
const newRole = await this.roleStore.create(baseRole);
if (rolePermissions) {
if (roleType === CUSTOM_ROOT_ROLE_TYPE) {
// this branch uses named permissions
await this.store.addPermissionsToRole(
newRole.id,
rolePermissions.map((p: NamePermissionRef) => p.name),
rolePermissions,
);
} else {
// this branch uses id permissions
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the reason why we're making this change, we want to be able to use names instead of ids for permissions

await this.store.addEnvironmentPermissionsToRole(
newRole.id,
rolePermissions,
Expand Down Expand Up @@ -673,7 +679,7 @@ export class AccessService {
if (roleType === CUSTOM_ROOT_ROLE_TYPE) {
await this.store.addPermissionsToRole(
newRole.id,
rolePermissions.map((p: NamePermissionRef) => p.name),
rolePermissions,
);
} else {
await this.store.addEnvironmentPermissionsToRole(
Expand Down
2 changes: 1 addition & 1 deletion src/lib/types/stores/access-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ export interface IAccessStore extends Store<IRole, number> {

addPermissionsToRole(
role_id: number,
permissions: string[],
permissions: PermissionRef[],
environment?: string,
): Promise<void>;

Expand Down
Loading
Loading