Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
117 changes: 117 additions & 0 deletions apps/backend/db_patches/9998_CaslPOC.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
DO
$$
BEGIN
IF register_patch('9998_CaslPOC.sql', 'scotthurley', 'Casl', '2026-01-29') THEN

BEGIN
CREATE TABLE IF NOT EXISTS permissions(
permission_id serial UNIQUE,
action varchar(100) DEFAULT NULL,
subject varchar(100) DEFAULT NULL,
conditions varchar(200) DEFAULT NULL,
is_db_permission bool DEFAULT false
);

CREATE TABLE IF NOT EXISTS role_has_permission(
role_id integer NOT NULL REFERENCES roles (role_id) ON UPDATE CASCADE ON DELETE CASCADE,
permission_id integer NOT NULL REFERENCES permissions (permission_id) ON UPDATE CASCADE ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);

INSERT INTO permissions(action, subject, conditions, is_db_permission) VALUES
('update', 'fap', '{ "user.userId": { "$in": "fap.fapChairUserIds" } }', false),
('archive', 'call', ' { "shortCode": { "$regex": "/LSF/i" }}', false),
('delete', 'proposal', '{"proposal.proposerId": "user.userId", "proposal.submitted": false}', false),
('read', 'proposal', '{"isInternalReviewer": true}', false),
('read', 'proposal', '{"isMemberOfFapProposal": true}', false),
('read', 'proposal_dashboard', NULL, false),
('delete', 'proposal', NULL, false),
('create', 'proposal', NULL, false),
('read', 'dashboard', NULL, false),
('read', 'experiment_times', NULL, false),
('read', 'call', NULL, false),
('read', 'proposal', NULL, false),
('read', 'permission', NULL, false),
('read', 'technique_proposal', NULL, false),
('read', 'experiment', NULL, false),
('read', 'fap', NULL, false),
('read', 'instrument', NULL, false),
('read', 'technique', NULL, false),
('read', 'tag', NULL, false),
('read', 'proposal_workflow', NULL, false),
('read', 'institution', NULL, false),
('read', 'people', NULL, false),
('read', 'question', NULL, false),
('read', 'setting', NULL, false),
('read', 'status_action_logs', NULL, false),
('read', 'template', NULL, false),
('read', 'proposal', '{"isVisitorOfProposal": true}', false),
('read', 'proposal', '{"isDataAccessUserOfProposal": true}', false),
('read', 'proposal', '{"isMemberOfProposal": true}', false),
Comment on lines +48 to +50
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

By having conditions for a subject split across separate rows, is it somehow possible to know which particular condition has returned false for clearer error messaging?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

separate rules collected into a single ability object are treated with the OR operator. So here the logic would be: "is the user a visitor of the proposal OR a data access user of the proposal OR a member of the proposal"

We could probably use something like this to see which conditions aren't met.

('read', 'proposal', '{"isInstrumentManagerToProposal": true}', false),
('read', 'proposal', '{"isScientistToProposalTechnique": true}', false),
('read', 'proposal', '{"isScientistToProposal": true}', false),
('read', 'fap_proposal_assignment', NULL, false),
('read', 'fap_proposal_assignment', '{"user_id": "userId"}', true),
Comment on lines +54 to +55
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This seems nice if I've interpreted it correctly: all of the permission filtering is handled at db level, so that the main check can be written without a condition for simplicity and performance.

In Casbin, if the filtering takes place and provides the exact outcome, it's still necessary to collect the context and evaluate each proposal again.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actually I'm not sure if my thinking was right. I can see the fap_reviewer role has these two permissions:

Image

But it looks like only the db permission one (34) is being used?

('update', 'fap', NULL, false);

INSERT INTO role_has_permission(role_id, permission_id) VALUES
(4,1),
(2,7),
(1,3),
(2,2),
(1,8),
(1,9),
(1,10),
(2,11),
(2,12),
(2,13),
(2,14),
(2,15),
(2,16),
(2,17),
(2,18),
(2,19),
(2,20),
(2,21),
(2,22),
(2,23),
(4,16),
(5,16),
(6,16),
(7,14),
(7,15),
(7,17),
(8,15),
(4,9),
(5,9),
(6,9),
(7,9),
(2,24),
(2,25),
(2,26),
(2,6),
(7,12),
(1,29),
(7,30),
(9,4),
(4,5),
(5,5),
(6,5),
(8,12),
(1,27),
(1,28),
(7,32),
(7,31),
(2,33),
(4,33),
(5,33),
(6,33),
(6,34),
(2,35);

END;
END IF;
END;
$$
LANGUAGE plpgsql;
78 changes: 78 additions & 0 deletions apps/backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"dependencies": {
"@apollo/server": "^4.10.0",
"@apollo/subgraph": "^2.7.1",
"@casl/ability": "^6.8.0",
"@graphql-tools/utils": "^10.1.0",
"@isaacs/ttlcache": "^1.4.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
Expand All @@ -46,6 +47,7 @@
"@opentelemetry/resource-detector-container": "^0.7.0",
"@opentelemetry/sdk-node": "^0.200.0",
"@opentelemetry/sdk-trace-node": "^2.0.0",
"@ucast/sql": "^0.1.0",
"@user-office-software/duo-localisation": "^1.2.0",
"@user-office-software/duo-logger": "^2.3.2",
"@user-office-software/duo-message-broker": "^1.8.0",
Expand Down
27 changes: 27 additions & 0 deletions apps/backend/src/auth/FapAuthorization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { inject, injectable } from 'tsyringe';
import { Tokens } from '../config/Tokens';
import { PermissionDataSource } from '../datasources/PermissionDataSource';
import { UserWithRole } from '../models/User';
import { FapDataSource } from '../datasources/FapDataSource';

@injectable()
export class FapAuthorization {
constructor(
@inject(Tokens.FapDataSource)
private fapDataSource: FapDataSource,
@inject(Tokens.PermissionDataSource)
private permissionDataSource: PermissionDataSource
) {}

async canEdit(agent: UserWithRole | null, fapId: number) {
const fap = await this.fapDataSource.getFap(fapId);

const user = {
role: agent?.currentRole?.shortCode,
userId: agent?.id,
};

const ctx = {fap, user}
return this.permissionDataSource.hasPermission(user.role ? user.role : 'user', 'update', 'fap', false, ctx);
}
}
66 changes: 38 additions & 28 deletions apps/backend/src/auth/ProposalAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Proposal } from '../resolvers/types/Proposal';
import { UserDataSource } from './../datasources/UserDataSource';
import { UserJWT } from './../models/User';
import { UserAuthorization } from './UserAuthorization';
import { PermissionDataSource } from '../datasources/PermissionDataSource';

@injectable()
export class ProposalAuthorization {
Expand All @@ -35,7 +36,9 @@ export class ProposalAuthorization {
private statusDataSource: StatusDataSource,
@inject(Tokens.DataAccessUsersDataSource)
private dataAccessUsersDataSource: DataAccessUsersDataSource,
@inject(Tokens.UserAuthorization) protected userAuth: UserAuthorization
@inject(Tokens.UserAuthorization) protected userAuth: UserAuthorization,
@inject(Tokens.PermissionDataSource)
private permissionDataSource: PermissionDataSource
) {}

private async resolveProposal(
Expand Down Expand Up @@ -258,50 +261,45 @@ export class ProposalAuthorization {

const currentRole = agent?.currentRole?.shortCode;

let hasAccess = false;
let permissionContext: any = null;
let isApiKey = false;

//I kept the switch statement with explicit reference to roles in order to avoid having to gather all the properties
//in a single context object, which would affect performance
switch (currentRole) {
case Roles.USER:
hasAccess =
(await this.isMemberOfProposal(agent, proposal)) ||
(await this.isVisitorOfProposal(agent, proposal.primaryKey)) ||
(await this.isDataAccessUserOfProposal(agent, proposal.primaryKey));
permissionContext = {
isMemberOfProposal: await this.isMemberOfProposal(agent, proposal),
isVisitorOfProposal: await this.isVisitorOfProposal(agent, proposal.primaryKey),
isDataAccessUserOfProposal: await this.isDataAccessUserOfProposal(agent, proposal.primaryKey)
};
break;
case Roles.INSTRUMENT_SCIENTIST:
hasAccess =
(await this.isInstrumentManagerToProposal(
agent,
proposal.primaryKey
)) ||
(await this.isScientistToProposal(agent, proposal.primaryKey)) ||
(await this.isScientistToProposalTechnique(
agent,
proposal.primaryKey
));
permissionContext = {
isInstrumentManagerToProposal: await this.isInstrumentManagerToProposal(agent, proposal.primaryKey),
isScientistToProposal: await this.isScientistToProposal(agent, proposal.primaryKey),
isScientistToProposalTechnique: await this.isScientistToProposalTechnique(agent, proposal.primaryKey)
};
break;
case Roles.INTERNAL_REVIEWER:
hasAccess = await this.isInternalReviewer(agent, proposal.primaryKey);
permissionContext = {
isInternalReviewer: await this.isInternalReviewer(agent, proposal.primaryKey)
};
break;
case Roles.FAP_REVIEWER:
case Roles.FAP_SECRETARY:
case Roles.FAP_CHAIR:
hasAccess = await this.isMemberOfFapProposal(
agent,
proposal.primaryKey
);
permissionContext = {
isMemberOfFapProposal: this.isMemberOfFapProposal(agent, proposal.primaryKey)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In case there's any weirdness with this permission - looks like there's an await forgotten. Happens to me often and usually has me scratching my head for far too long

};
break;
case Roles.USER_OFFICER:
hasAccess = true;
break;
case Roles.EXPERIMENT_SAFETY_REVIEWER:
hasAccess = true;
break;

default:
hasAccess = this.userAuth.hasGetAccessByToken(agent);
isApiKey = true;
}

return hasAccess;
return isApiKey? this.userAuth.hasGetAccessByToken(agent) : await this.permissionDataSource.hasPermission(currentRole ? currentRole : 'user', 'read', 'proposal', false, permissionContext); //probably need to change
}

private async isProposalEditable(
Expand Down Expand Up @@ -366,4 +364,16 @@ export class ProposalAuthorization {

return false;
}

async canDelete(agent: UserWithRole | null, proposalPk: number) {
const proposal = await this.proposalDataSource.get(proposalPk);

const user = {
role: agent?.currentRole?.shortCode,
userId: agent?.id,
};

const ctx = {proposal, user}
return this.permissionDataSource.hasPermission(user.role ? user.role : 'user', 'delete', 'proposal', false, ctx);
}
}
Loading
Loading