Skip to content

Commit

Permalink
Support organizational roles
Browse files Browse the repository at this point in the history
Users in our system can be associated with the three types of
organization in the PDC (changemakers, funders, and data providers).
These associations will allow them access to perform various actions in
the context of those organizations.

For instance, reading data, writing data, or managing other user
associations. This list of abilities may change in future.

We explored the concept of `user_roles` with foreign keys to different
organization types (similar to the sources table) but decided to have
three separate tables because they are slightly distinct concepts.  For
instance, there will probably be certain access types that only apply to
certain types of organization in future.

Another design decision was to have the permissions in terms of granted
access type rather than higher level role. For instance, instead of
roles like "administrator" and "editor" we have action oriented roles
like "read" and "manage".

This provides more granularity and is also more explicit about what
a given role / access type actually allows.

Issue #1250 Support associations between users and organizational entities
  • Loading branch information
slifty committed Oct 28, 2024
1 parent 1e0e1ac commit ad05885
Show file tree
Hide file tree
Showing 26 changed files with 642 additions and 3 deletions.
3 changes: 3 additions & 0 deletions docs/ENTITY_RELATIONSHIP_DIAGRAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ erDiagram
Proposal }o--|| User : "is created by"
ProposalVersion }o--|| User : "is created by"
BulkUpload }o--|| User : "is created by"
User }o--o{ Changemaker : "is granted access within"
User }o--o{ Funder : "is granted access within"
User }o--o{ DataProvider : "is granted access within"
```

## Narrative
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,5 +101,6 @@
"tinypg": "^7.0.0",
"tmp-promise": "^3.0.3",
"uuid": "^10.0.0"
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
117 changes: 115 additions & 2 deletions src/__tests__/users.int.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import request from 'supertest';
import { v4 as uuidv4 } from 'uuid';
import { app } from '../app';
import { createUser, loadSystemUser, loadTableMetrics } from '../database';
import {
createChangemaker,
createOrUpdateChangemakerRole,
createOrUpdateDataProvider,
createOrUpdateDataProviderRole,
createOrUpdateFunder,
createOrUpdateFunderRole,
createUser,
loadSystemUser,
loadTableMetrics,
} from '../database';
import { expectTimestamp, loadTestUser } from '../test/utils';
import {
mockJwt as authHeader,
mockJwtWithAdminRole as authHeaderWithAdminRole,
} from '../test/mockJwt';
import { keycloakUserIdToString, stringToKeycloakUserId } from '../types';
import { AccessType } from '../types/AccessType';

const createAdditionalTestUser = async () =>
createUser({
Expand All @@ -33,7 +44,84 @@ describe('/users', () => {
.expect(200);
expect(response.body).toEqual({
total: userCount,
entries: [testUser],
entries: [
{
keycloakUserId: testUser.keycloakUserId,
roles: {
changemaker: {},
dataProvider: {},
funder: {},
},
createdAt: expectTimestamp,
},
],
});
});

it('returns the roles associated with a user', async () => {
const systemUser = await loadSystemUser();
const testUser = await loadTestUser();
const dataProvider = await createOrUpdateDataProvider({
name: 'Test Provider',
shortCode: 'testProvider',
});
const funder = await createOrUpdateFunder({
name: 'Test Funder',
shortCode: 'testFunder',
});
const changemaker = await createChangemaker({
name: 'Test Changemaker',
taxId: '12-3456789',
});
await createOrUpdateDataProviderRole({
dataProviderShortCode: dataProvider.shortCode,
userKeycloakUserId: testUser.keycloakUserId,
accessType: AccessType.MANAGE,
createdBy: systemUser.keycloakUserId,
});
await createOrUpdateFunderRole({
funderShortCode: funder.shortCode,
userKeycloakUserId: testUser.keycloakUserId,
accessType: AccessType.EDIT,
createdBy: systemUser.keycloakUserId,
});
await createOrUpdateChangemakerRole({
changemakerId: changemaker.id,
userKeycloakUserId: testUser.keycloakUserId,
accessType: AccessType.VIEW,
createdBy: systemUser.keycloakUserId,
});
const { count: userCount } = await loadTableMetrics('users');

const response = await request(app)
.get('/users')
.set(authHeader)
.expect(200);
expect(response.body).toEqual({
total: userCount,
entries: [
{
keycloakUserId: testUser.keycloakUserId,
roles: {
changemaker: {
[changemaker.id]: {
view: true,
},
},
dataProvider: {
testProvider: {
manage: true,
},
},
funder: {
testFunder: {
edit: true,
},
},
},
createdAt: expectTimestamp,
},
],
});
});

Expand Down Expand Up @@ -99,22 +187,47 @@ describe('/users', () => {
entries: [
{
keycloakUserId: uuids[14],
roles: {
changemaker: {},
dataProvider: {},
funder: {},
},
createdAt: expectTimestamp,
},
{
keycloakUserId: uuids[13],
roles: {
changemaker: {},
dataProvider: {},
funder: {},
},
createdAt: expectTimestamp,
},
{
keycloakUserId: uuids[12],
roles: {
changemaker: {},
dataProvider: {},
funder: {},
},
createdAt: expectTimestamp,
},
{
keycloakUserId: uuids[11],
roles: {
changemaker: {},
dataProvider: {},
funder: {},
},
createdAt: expectTimestamp,
},
{
keycloakUserId: uuids[10],
roles: {
changemaker: {},
dataProvider: {},
funder: {},
},
createdAt: expectTimestamp,
},
],
Expand Down
14 changes: 14 additions & 0 deletions src/database/initialization/changemaker_role_to_json.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
SELECT drop_function('changemaker_role_to_json');

CREATE FUNCTION changemaker_role_to_json(changemaker_role changemaker_roles)
RETURNS JSONB AS $$
BEGIN
RETURN jsonb_build_object(
'userKeycloakUserId', changemaker_role.user_keycloak_user_id,
'changemakerId', changemaker_role.changemaker_id,
'accessType', changemaker_role.access_type,
'createdBy', changemaker_role.created_by,
'createdAt', changemaker_role.created_at
);
END;
$$ LANGUAGE plpgsql;
14 changes: 14 additions & 0 deletions src/database/initialization/data_provider_role_to_json.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
SELECT drop_function('data_provider_role_to_json');

CREATE FUNCTION data_provider_role_to_json(data_provider_role data_provider_roles)
RETURNS JSONB AS $$
BEGIN
RETURN jsonb_build_object(
'userKeycloakUserId', data_provider_role.user_keycloak_user_id,
'dataProviderShortCode', data_provider_role.data_provider_short_code,
'accessType', data_provider_role.access_type,
'createdBy', data_provider_role.created_by,
'createdAt', data_provider_role.created_at
);
END;
$$ LANGUAGE plpgsql;
14 changes: 14 additions & 0 deletions src/database/initialization/funder_role_to_json.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
SELECT drop_function('funder_role_to_json');

CREATE FUNCTION funder_role_to_json(funder_role funder_roles)
RETURNS JSONB AS $$
BEGIN
RETURN jsonb_build_object(
'userKeycloakUserId', funder_role.user_keycloak_user_id,
'funderShortCode', funder_role.funder_short_code,
'accessType', funder_role.access_type,
'createdBy', funder_role.created_by,
'createdAt', funder_role.created_at
);
END;
$$ LANGUAGE plpgsql;
48 changes: 48 additions & 0 deletions src/database/initialization/user_to_json.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,57 @@ SELECT drop_function('user_to_json');

CREATE FUNCTION user_to_json("user" users)
RETURNS JSONB AS $$
DECLARE
roles_json JSONB := NULL::JSONB;
changemaker_roles_json JSONB := NULL::JSONB;
funder_roles_json JSONB := NULL::JSONB;
data_provider_roles_json JSONB := NULL::JSONB;
BEGIN
changemaker_roles_json := (
SELECT jsonb_object_agg(
changemaker_role_maps.changemaker_id, changemaker_role_maps.role_map
)
FROM (
SELECT changemaker_roles.changemaker_id, jsonb_object_agg(changemaker_roles.access_type, TRUE) AS role_map
FROM changemaker_roles
WHERE changemaker_roles.user_keycloak_user_id = "user".keycloak_user_id
GROUP BY changemaker_roles.changemaker_id
) AS changemaker_role_maps
);

data_provider_roles_json := (
SELECT jsonb_object_agg(
data_provider_role_maps.data_provider_short_code, data_provider_role_maps.role_map
)
FROM (
SELECT data_provider_roles.data_provider_short_code, jsonb_object_agg(data_provider_roles.access_type, TRUE) AS role_map
FROM data_provider_roles
WHERE data_provider_roles.user_keycloak_user_id = "user".keycloak_user_id
GROUP BY data_provider_roles.data_provider_short_code
) AS data_provider_role_maps
);

funder_roles_json := (
SELECT jsonb_object_agg(
funder_role_maps.funder_short_code, funder_role_maps.role_map
)
FROM (
SELECT funder_roles.funder_short_code, jsonb_object_agg(funder_roles.access_type, TRUE) AS role_map
FROM funder_roles
WHERE funder_roles.user_keycloak_user_id = "user".keycloak_user_id
GROUP BY funder_roles.funder_short_code
) AS funder_role_maps
);

roles_json := jsonb_build_object(
'changemaker', COALESCE(changemaker_roles_json, '{}'),
'dataProvider', COALESCE(data_provider_roles_json, '{}'),
'funder', COALESCE(funder_roles_json, '{}')
);

RETURN jsonb_build_object(
'keycloakUserId', "user".keycloak_user_id,
'roles', roles_json,
'createdAt', "user".created_at
);
END;
Expand Down
17 changes: 17 additions & 0 deletions src/database/migrations/0040-create-changemaker_roles.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
CREATE TYPE access_type_t AS ENUM (
'manage',
'edit',
'view'
);

CREATE TABLE changemaker_roles (
user_keycloak_user_id UUID NOT NULL,
changemaker_id INT NOT NULL,
access_type access_type_t NOT NULL,
created_by UUID NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_keycloak_user_id, changemaker_id, access_type),
FOREIGN KEY (created_by) REFERENCES users(keycloak_user_id) ON DELETE CASCADE,
FOREIGN KEY (user_keycloak_user_id) REFERENCES users(keycloak_user_id) ON DELETE CASCADE,
FOREIGN KEY (changemaker_id) REFERENCES changemakers(id) ON DELETE CASCADE
);
11 changes: 11 additions & 0 deletions src/database/migrations/0041-create-funder_roles.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
CREATE TABLE funder_roles (
user_keycloak_user_id UUID NOT NULL,
funder_short_code short_code_t NOT NULL,
access_type access_type_t NOT NULL,
created_by UUID NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_keycloak_user_id, funder_short_code, access_type),
FOREIGN KEY (created_by) REFERENCES users(keycloak_user_id) ON DELETE CASCADE,
FOREIGN KEY (user_keycloak_user_id) REFERENCES users(keycloak_user_id) ON DELETE CASCADE,
FOREIGN KEY (funder_short_code) REFERENCES funders(short_code) ON DELETE CASCADE
);
11 changes: 11 additions & 0 deletions src/database/migrations/0042-create-data_provider_roles.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
CREATE TABLE data_provider_roles (
user_keycloak_user_id UUID NOT NULL,
data_provider_short_code short_code_t NOT NULL,
access_type access_type_t NOT NULL,
created_by UUID NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_keycloak_user_id, data_provider_short_code, access_type),
FOREIGN KEY (created_by) REFERENCES users(keycloak_user_id) ON DELETE CASCADE,
FOREIGN KEY (user_keycloak_user_id) REFERENCES users(keycloak_user_id) ON DELETE CASCADE,
FOREIGN KEY (data_provider_short_code) REFERENCES data_providers(short_code) ON DELETE CASCADE
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { db } from '../../db';
import type {
DataProviderRole,
InternallyWritableChangemakerRole,
JsonResultSet,
} from '../../../types';

const createOrUpdateChangemakerRole = async (
createValues: InternallyWritableChangemakerRole,
): Promise<DataProviderRole> => {
const { userKeycloakUserId, changemakerId, accessType, createdBy } =
createValues;
const result = await db.sql<JsonResultSet<DataProviderRole>>(
'changemakerRoles.insertOrUpdateOne',
{
changemakerId,
userKeycloakUserId,
accessType,
createdBy,
},
);

const { object } = result.rows[0] ?? {};
if (object === undefined) {
throw new Error(
'The entity creation did not appear to fail, but no data was returned by the operation.',
);
}
return object;
};

export { createOrUpdateChangemakerRole };
1 change: 1 addition & 0 deletions src/database/operations/changemakerRoles/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './createOrUpdateChangemakerRole';
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { db } from '../../db';
import type {
DataProviderRole,
InternallyWritableDataProviderRole,
JsonResultSet,
} from '../../../types';

const createOrUpdateDataProviderRole = async (
createValues: InternallyWritableDataProviderRole,
): Promise<DataProviderRole> => {
const { userKeycloakUserId, dataProviderShortCode, accessType, createdBy } =
createValues;
const result = await db.sql<JsonResultSet<DataProviderRole>>(
'dataProviderRoles.insertOrUpdateOne',
{
dataProviderShortCode,
userKeycloakUserId,
accessType,
createdBy,
},
);

const { object } = result.rows[0] ?? {};
if (object === undefined) {
throw new Error(
'The entity creation did not appear to fail, but no data was returned by the operation.',
);
}
return object;
};

export { createOrUpdateDataProviderRole };
1 change: 1 addition & 0 deletions src/database/operations/dataProviderRoles/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './createOrUpdateDataProviderRole';
Loading

0 comments on commit ad05885

Please sign in to comment.