Skip to content

Commit

Permalink
Support user permissions
Browse files Browse the repository at this point in the history
Users in our system can be given permission to three kinds 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 Nov 7, 2024
1 parent 4658527 commit 07da57a
Show file tree
Hide file tree
Showing 24 changed files with 652 additions and 4 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Upgraded to use OpenAPI Specification 3.1.

## 0.16.0 2024-11-7

### Added

- `User` now has a `permissions` attribute which includes information about various granted permissions.

## 0.15.1 2024-10-28

### Fixed
Expand Down
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 permissions for"
User }o--o{ Funder : "is granted permissions for"
User }o--o{ DataProvider : "is granted permissions for"
```

## Narrative
Expand Down
116 changes: 113 additions & 3 deletions src/__tests__/users.int.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import request from 'supertest';
import { v4 as uuidv4 } from 'uuid';
import { app } from '../app';
import { createUser, loadSystemUser, loadTableMetrics } from '../database';
import {
createChangemaker,
createOrUpdateDataProvider,
createOrUpdateFunder,
createOrUpdateUserChangemakerPermission,
createOrUpdateUserDataProviderPermission,
createOrUpdateUserFunderPermission,
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 {
keycloakUserIdToString,
stringToKeycloakUserId,
Permission,
} from '../types';

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

it('returns the permissions 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 createOrUpdateUserDataProviderPermission({
userKeycloakUserId: testUser.keycloakUserId,
permission: Permission.MANAGE,
dataProviderShortCode: dataProvider.shortCode,
createdBy: systemUser.keycloakUserId,
});
await createOrUpdateUserFunderPermission({
userKeycloakUserId: testUser.keycloakUserId,
permission: Permission.EDIT,
funderShortCode: funder.shortCode,
createdBy: systemUser.keycloakUserId,
});
await createOrUpdateUserChangemakerPermission({
userKeycloakUserId: testUser.keycloakUserId,
permission: Permission.VIEW,
changemakerId: changemaker.id,
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,
permissions: {
changemaker: {
[changemaker.id]: [Permission.VIEW],
},
dataProvider: {
testProvider: [Permission.MANAGE],
},
funder: {
testFunder: [Permission.EDIT],
},
},
createdAt: expectTimestamp,
},
],
});
});

Expand Down Expand Up @@ -99,22 +184,47 @@ describe('/users', () => {
entries: [
{
keycloakUserId: uuids[14],
permissions: {
changemaker: {},
dataProvider: {},
funder: {},
},
createdAt: expectTimestamp,
},
{
keycloakUserId: uuids[13],
permissions: {
changemaker: {},
dataProvider: {},
funder: {},
},
createdAt: expectTimestamp,
},
{
keycloakUserId: uuids[12],
permissions: {
changemaker: {},
dataProvider: {},
funder: {},
},
createdAt: expectTimestamp,
},
{
keycloakUserId: uuids[11],
permissions: {
changemaker: {},
dataProvider: {},
funder: {},
},
createdAt: expectTimestamp,
},
{
keycloakUserId: uuids[10],
permissions: {
changemaker: {},
dataProvider: {},
funder: {},
},
createdAt: expectTimestamp,
},
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
SELECT drop_function('user_changemaker_permission_to_json');

CREATE FUNCTION user_changemaker_permission_to_json(user_changemaker_permission user_changemaker_permissions)
RETURNS JSONB AS $$
BEGIN
RETURN jsonb_build_object(
'userKeycloakUserId', user_changemaker_permission.user_keycloak_user_id,
'permission', user_changemaker_permission.permission,
'changemakerId', user_changemaker_permission.changemaker_id,
'createdBy', user_changemaker_permission.created_by,
'createdAt', user_changemaker_permission.created_at
);
END;
$$ LANGUAGE plpgsql;
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
SELECT drop_function('user_data_provider_permission_to_json');

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

CREATE FUNCTION user_funder_permission_to_json(user_funder_permission user_funder_permissions)
RETURNS JSONB AS $$
BEGIN
RETURN jsonb_build_object(
'userKeycloakUserId', user_funder_permission.user_keycloak_user_id,
'permission', user_funder_permission.permission,
'funderShortCode', user_funder_permission.funder_short_code,
'createdBy', user_funder_permission.created_by,
'createdAt', user_funder_permission.created_at
);
END;
$$ LANGUAGE plpgsql;
47 changes: 47 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,56 @@ SELECT drop_function('user_to_json');

CREATE FUNCTION user_to_json("user" users)
RETURNS JSONB AS $$
DECLARE
permissions_json JSONB := NULL::JSONB;
user_changemaker_permissions_json JSONB := NULL::JSONB;
user_funder_permissions_json JSONB := NULL::JSONB;
user_data_provider_permissions_json JSONB := NULL::JSONB;
BEGIN
user_changemaker_permissions_json := (
SELECT jsonb_object_agg(
aggregated_user_changemaker_permissions.changemaker_id, aggregated_user_changemaker_permissions.permissions
)
FROM (
SELECT user_changemaker_permissions.changemaker_id, jsonb_agg(user_changemaker_permissions.permission) AS permissions
FROM user_changemaker_permissions
WHERE user_changemaker_permissions.user_keycloak_user_id = "user".keycloak_user_id
GROUP BY user_changemaker_permissions.changemaker_id
) AS aggregated_user_changemaker_permissions
);

user_data_provider_permissions_json := (
SELECT jsonb_object_agg(
aggregated_user_data_provider_permissions.data_provider_short_code, aggregated_user_data_provider_permissions.permissions
)
FROM (
SELECT user_data_provider_permissions.data_provider_short_code, jsonb_agg(user_data_provider_permissions.permission) AS permissions
FROM user_data_provider_permissions
WHERE user_data_provider_permissions.user_keycloak_user_id = "user".keycloak_user_id
GROUP BY user_data_provider_permissions.data_provider_short_code
) AS aggregated_user_data_provider_permissions );

user_funder_permissions_json := (
SELECT jsonb_object_agg(
aggregated_user_funder_permissions.funder_short_code, aggregated_user_funder_permissions.permissions
)
FROM (
SELECT user_funder_permissions.funder_short_code, jsonb_agg(user_funder_permissions.permission) AS permissions
FROM user_funder_permissions
WHERE user_funder_permissions.user_keycloak_user_id = "user".keycloak_user_id
GROUP BY user_funder_permissions.funder_short_code
) AS aggregated_user_funder_permissions
);

permissions_json := jsonb_build_object(
'changemaker', COALESCE(user_changemaker_permissions_json, '{}'),
'dataProvider', COALESCE(user_data_provider_permissions_json, '{}'),
'funder', COALESCE(user_funder_permissions_json, '{}')
);

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

CREATE TABLE user_changemaker_permissions (
user_keycloak_user_id UUID NOT NULL REFERENCES users(keycloak_user_id) ON DELETE CASCADE,
permission permission_t NOT NULL,
changemaker_id INT NOT NULL REFERENCES changemakers(id) ON DELETE CASCADE,
created_by UUID NOT NULL REFERENCES users(keycloak_user_id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_keycloak_user_id, permission, changemaker_id)
);

CREATE TABLE user_funder_permissions (
user_keycloak_user_id UUID NOT NULL REFERENCES users(keycloak_user_id) ON DELETE CASCADE,
permission permission_t NOT NULL,
funder_short_code short_code_t NOT NULL REFERENCES funders(short_code) ON DELETE CASCADE,
created_by UUID NOT NULL REFERENCES users(keycloak_user_id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_keycloak_user_id, permission, funder_short_code)
);

CREATE TABLE user_data_provider_permissions (
user_keycloak_user_id UUID NOT NULL REFERENCES users(keycloak_user_id) ON DELETE CASCADE,
permission permission_t NOT NULL,
data_provider_short_code short_code_t NOT NULL REFERENCES data_providers(short_code) ON DELETE CASCADE,
created_by UUID NOT NULL REFERENCES users(keycloak_user_id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_keycloak_user_id, permission, data_provider_short_code)
);
3 changes: 3 additions & 0 deletions src/database/operations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,7 @@ export * from './proposalFieldValues';
export * from './proposals';
export * from './proposalVersions';
export * from './sources';
export * from './userChangemakerPermissions';
export * from './userDataProviderPermissions';
export * from './userFunderPermissions';
export * from './users';
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { db } from '../../db';
import type {
InternallyWritableUserChangemakerPermission,
JsonResultSet,
UserChangemakerPermission,
} from '../../../types';

const createOrUpdateUserChangemakerPermission = async (
createValues: InternallyWritableUserChangemakerPermission,
): Promise<UserChangemakerPermission> => {
const { userKeycloakUserId, changemakerId, permission, createdBy } =
createValues;
const result = await db.sql<JsonResultSet<UserChangemakerPermission>>(
'userChangemakerPermissions.insertOrUpdateOne',
{
userKeycloakUserId,
permission,
changemakerId,
createdBy,
},
);

const { object } = result.rows[0] ?? {};
if (object === undefined) {
throw new Error(

Check warning on line 25 in src/database/operations/userChangemakerPermissions/createOrUpdateUserChangemakerPermission.ts

View check run for this annotation

Codecov / codecov/patch

src/database/operations/userChangemakerPermissions/createOrUpdateUserChangemakerPermission.ts#L25

Added line #L25 was not covered by tests
'The entity creation did not appear to fail, but no data was returned by the operation.',
);
}
return object;
};

export { createOrUpdateUserChangemakerPermission };
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './createOrUpdateUserChangemakerPermission';
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { db } from '../../db';
import type {
UserDataProviderPermission,
InternallyWritableUserDataProviderPermission,
JsonResultSet,
} from '../../../types';

const createOrUpdateUserDataProviderPermission = async (
createValues: InternallyWritableUserDataProviderPermission,
): Promise<UserDataProviderPermission> => {
const { userKeycloakUserId, dataProviderShortCode, permission, createdBy } =
createValues;
const result = await db.sql<JsonResultSet<UserDataProviderPermission>>(
'userDataProviderPermissions.insertOrUpdateOne',
{
userKeycloakUserId,
permission,
dataProviderShortCode,
createdBy,
},
);

const { object } = result.rows[0] ?? {};
if (object === undefined) {
throw new Error(

Check warning on line 25 in src/database/operations/userDataProviderPermissions/createOrUpdateUserDataProviderPermission.ts

View check run for this annotation

Codecov / codecov/patch

src/database/operations/userDataProviderPermissions/createOrUpdateUserDataProviderPermission.ts#L25

Added line #L25 was not covered by tests
'The entity creation did not appear to fail, but no data was returned by the operation.',
);
}
return object;
};

export { createOrUpdateUserDataProviderPermission };
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './createOrUpdateUserDataProviderPermission';
Loading

0 comments on commit 07da57a

Please sign in to comment.