Skip to content
Draft
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
2,612 changes: 1,760 additions & 852 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
"express-validator": "^6.4.0",
"joi": "^17.2.1",
"joi-to-swagger": "^6.2.0",
"jsonwebtoken": "^9.0.3",
"jwks-rsa": "^3.2.0",
"knex": "^0.95.11",
"loglevel": "^1.8.0",
"multer": "^1.4.5-lts.1",
Expand Down
19 changes: 10 additions & 9 deletions server/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ const { join } = require('path');

// const Sentry = require('@sentry/node');
const { EventHandlerService } = require('./services/EventHandlerService');
const { errorHandler, handlerWrapper } = require('./utils/utils');
const {
errorHandler,
handlerWrapper,
verifyJWTHandler,
} = require('./utils/utils');
const swaggerDocument = require('./handlers/swaggerDoc');
const HttpError = require('./utils/HttpError');
const router = require('./routes');
Expand Down Expand Up @@ -63,20 +67,17 @@ const options = {

app.use('/assets', express.static(join(__dirname, '..', '/assets')));

app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument, options));
app.use('/swagger', swaggerUi.serve, swaggerUi.setup(swaggerDocument, options));
app.use(express.urlencoded({ extended: false }));
app.use(express.json());

app.use('/', router);
app.get('/swagger.json', (req, res) => {
res.json(swaggerDocument);
});
app.use('/', verifyJWTHandler, router);

app.use(errorHandler);

const { version } = require('../package.json');

app.get('*', function (req, res) {
res.status(200).send(version);
});

const eventHandlerService = new EventHandlerService();
(async () => {
await eventHandlerService.registerEventHandlers();
Expand Down
4 changes: 2 additions & 2 deletions server/routes/growerAccountRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ const {
router
.route('/grower_accounts')
.get(handlerWrapper(growerAccountHandlerGet))
.post(handlerWrapper(growerAccountHandlerPost))
.put(handlerWrapper(growerAccountHandlerPut));
.post(handlerWrapper(growerAccountHandlerPost)) // feature flag
.put(handlerWrapper(growerAccountHandlerPut)); // M2M token for transformer-v2

router
.route('/grower_accounts/:grower_account_id')
Expand Down
65 changes: 55 additions & 10 deletions server/routes/treeRoutes.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const express = require('express');

const router = express.Router();
const { handlerWrapper } = require('../utils/utils');
const { handlerWrapper, hasPermission } = require('../utils/utils');

const {
treeHandlerPost,
Expand All @@ -14,29 +14,74 @@ const {
treeHandlerSingleTagGet,
treeHandlerSingleTagPatch,
} = require('../handlers/treeHandler');
const { ROLES, POLICIES } = require('../utils/enums');

router
.route('/trees')
.get(handlerWrapper(treeHandlerGet))
.post(handlerWrapper(treeHandlerPost));
.get(
hasPermission(
[POLICIES.LIST_TREE, POLICIES.APPROVE_TREE],
ROLES.tree_manager,
),
handlerWrapper(treeHandlerGet),
)
.post(
hasPermission([POLICIES.APPROVE_TREE], ROLES.tree_manager),
handlerWrapper(treeHandlerPost),
);

router
.route('/trees/potential_matches')
.get(handlerWrapper(treeHandlerGetPotentialMatches));
.get(
hasPermission(
[POLICIES.MATCH_CAPTURES, POLICIES.LIST_TREE, POLICIES.APPROVE_TREE],
ROLES.tree_manager,
),
handlerWrapper(treeHandlerGetPotentialMatches),
);

router
.route('/trees/:tree_id')
.get(handlerWrapper(treeHandlerSingleGet))
.patch(handlerWrapper(treeHandlerPatch));
.get(
hasPermission(
[POLICIES.LIST_TREE, POLICIES.APPROVE_TREE],
ROLES.tree_manager,
),
handlerWrapper(treeHandlerSingleGet),
)
.patch(
hasPermission([POLICIES.APPROVE_TREE], ROLES.tree_manager),
handlerWrapper(treeHandlerPatch),
);

router
.route('/trees/:tree_id/tags')
.get(handlerWrapper(treeHandlerTagGet))
.post(handlerWrapper(treeHandlerTagPost));
.get(
hasPermission(
[POLICIES.LIST_TREE, POLICIES.APPROVE_TREE],
ROLES.tree_manager,
),
handlerWrapper(treeHandlerTagGet),
)
.post(
hasPermission([POLICIES.APPROVE_TREE], ROLES.tree_manager),
handlerWrapper(treeHandlerTagPost),
);

router
.route('/trees/:tree_id/tags/:tag_id')
.get(handlerWrapper(treeHandlerSingleTagGet))
.patch(handlerWrapper(treeHandlerSingleTagPatch));
.get(
hasPermission(
[POLICIES.LIST_TREE, POLICIES.APPROVE_TREE],
ROLES.tree_manager,
),
handlerWrapper(treeHandlerSingleTagGet),
)
.patch(
hasPermission([POLICIES.APPROVE_TREE], ROLES.tree_manager),
handlerWrapper(treeHandlerSingleTagPatch),
);

module.exports = router;

// audit/event service is needed
88 changes: 88 additions & 0 deletions server/services/JwtService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
const JWTTools = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
const log = require('loglevel');
const HttpError = require('../utils/HttpError');

class JWTService {
static async verify(authorization) {
if (!authorization) {
throw new HttpError(
401,
'ERROR: Authentication, no token supplied for protected path',
);
}
const tokenArray = authorization.split('Bearer ');
const token = tokenArray[1];
let userId; let roles; let policies; let organizationId;
if (token) {
// get the public key
const KEYCLOAK_URL =
process.env.KEYCLOAK_URL ||
// 'http://keycloak-service.keycloak:8080/keycloak/realms/treetracker';
'http://localhost:8081/realms/master';

const client = jwksClient({
jwksUri: `${KEYCLOAK_URL}/protocol/openid-connect/certs`,
});
const r = await client.getSigningKey();
const publicKey = r.getPublicKey();

// Decode the token
JWTTools.verify(
token,
publicKey,
{
issuer: KEYCLOAK_URL,
algorithms: ['RS256'],
},
(err, decod) => {
if (err) {
log.error(err?.message);
throw new HttpError(
401,
'ERROR: Authentication, token not verified',
);
}

if (!decod?.sub)
throw new HttpError(
401,
'ERROR: Authentication, invalid token received',
);
const orgInfo = decod?.organization;
if (orgInfo) {
roles = Object.keys(orgInfo).filter((k) => k.startsWith('role_'));
policies = roles
.filter((k) => orgInfo[k].policy)
.map((k) => orgInfo[k].policy);
policies = [...new Set(policies.flat())];

const organization = Object.keys(orgInfo).filter(
(k) => !orgInfo[k].policy,
);
if (organization.length > 1) {
throw new HttpError(
500,
'user cannot belong to more than one organization',
);
}
organizationId = orgInfo[organization[0]]?.id;
} else {
log.error('no log info found');
throw new HttpError(
401,
'ERROR: Authentication, invalid token received',
);
}

userId = decod.sub;
},
);
} else {
throw new HttpError(401, 'ERROR: Authentication, invalid token received');
}
return { id: userId, organizationId, roles, policies };
}
}

module.exports = JWTService;
18 changes: 17 additions & 1 deletion server/utils/enums.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,20 @@ const DomainEventTypes = {
RawCaptureRejected: 'RawCaptureRejected',
};

module.exports = { DomainEventTypes };
const ROLES = {
super_admin: 'role_super_admin',
planter_manager: 'role_planter_manager',
tree_manager: 'role_tree_manager',
};

const POLICIES = {
SUPER_PERMISSION: 'super_permission',
MANAGE_GROWER: 'manage_planter',
LIST_GROWER: 'list_planter',
LIST_TREE: 'list_tree',
APPROVE_TREE: 'approve_tree',
MATCH_CAPTURES: 'match_captures',
FEATURE_FLAG: 'feature_flag',
};

module.exports = { DomainEventTypes, ROLES, POLICIES };
40 changes: 37 additions & 3 deletions server/utils/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const log = require('loglevel');
const { ValidationError } = require('joi');
const { isAxiosError } = require('axios').default;
const HttpError = require('./HttpError');
const JWTService = require('../services/JwtService');
const { POLICIES } = require('./enums');

/*
* This is from the library https://github.com/Abazhenov/express-async-handler
Expand All @@ -31,8 +33,7 @@ exports.handlerWrapper = (fn) =>
};

exports.errorHandler = (err, req, res, _next) => {
if (!isAxiosError(err)) log.warn('catch error:', err);
if (err instanceof HttpError) {
if (err instanceof HttpError && err.code !== 500) {
res.status(err.code).send({
code: err.code,
message: err.message,
Expand Down Expand Up @@ -61,9 +62,42 @@ exports.errorHandler = (err, req, res, _next) => {
message: errorObject.message,
});
} else {
log.error(err);
res.status(500).send({
code: 500,
message: `Unknown error (${err.message})`,
message: `Internal server error`,
});
}
};

exports.verifyJWTHandler = exports.handlerWrapper(async (req, res, next) => {
const result = await JWTService.verify(req.headers.authorization);
req.user = { ...result };
next();
});

exports.hasPermission = (allowedPolicies, allowedRole) =>
exports.handlerWrapper((req, res, next) => {
const { policies: userPolicies, organizationId, roles } = req.user;

if (userPolicies.includes(POLICIES.SUPER_PERMISSION)) {
return next();
}
if (!allowedPolicies?.length) {
throw new HttpError(500, 'no access control defined for route');
}

const permittedViaPolicies = allowedPolicies.some((item) =>
userPolicies.includes(item),
);

if (!permittedViaPolicies) {
throw new HttpError(401, 'not permitted to access resource');
}

if (!organizationId && !roles.includes(allowedRole)) {
throw new HttpError(401, 'not attached to an organization');
}

return next();
});