app/src/controllers/bucket.js
const Problem = require('api-problem');
const { UniqueViolationError } = require('objection');
const { NIL: SYSTEM_USER } = require('uuid');
const { DEFAULTREGION, Permissions } = require('../components/constants');
const errorToProblem = require('../components/errorToProblem');
const log = require('../components/log')(module.filename);
const {
addDashesToUuid,
getCurrentIdentity,
isTruthy,
joinPath,
mixedQueryToArray,
stripDelimit
} = require('../components/utils');
const { redactSecrets } = require('../db/models/utils');
const { bucketService, storageService, userService } = require('../services');
const SERVICE = 'BucketService';
const secretFields = ['accessKeyId', 'secretAccessKey'];
/**
* The Bucket Controller
*/
const controller = {
/**
* @function _processS3Headers
* Accepts a typical S3 response object and inserts appropriate express response headers
* Returns an array of non-standard headers that need to be CORS exposed
* @param {object} s3Resp S3 response object
* @param {object} res Express response object
* @returns {string[]} An array of non-standard headers that need to be CORS exposed
*/
_processS3Headers(s3Resp, res) {
// TODO: Consider adding 'x-coms-public' and 'x-coms-path' headers into API spec?
const exposedHeaders = [];
if (s3Resp.ContentLength) res.set('Content-Length', s3Resp.ContentLength);
if (s3Resp.ContentType) res.set('Content-Type', s3Resp.ContentType);
if (s3Resp.ETag) {
const etag = 'ETag';
res.set(etag, s3Resp.ETag);
exposedHeaders.push(etag);
}
if (s3Resp.LastModified) res.set('Last-Modified', s3Resp.LastModified);
if (s3Resp.Metadata) {
Object.entries(s3Resp.Metadata).forEach(([key, value]) => {
const metadata = `x-amz-meta-${key}`;
res.set(metadata, value);
exposedHeaders.push(metadata);
});
if (s3Resp.Metadata.name) res.attachment(s3Resp.Metadata.name);
}
if (s3Resp.ServerSideEncryption) {
const sse = 'x-amz-server-side-encryption';
res.set(sse, s3Resp.ServerSideEncryption);
exposedHeaders.push(sse);
}
if (s3Resp.VersionId) {
const s3VersionId = 'x-amz-version-id';
res.set(s3VersionId, s3Resp.VersionId);
exposedHeaders.push(s3VersionId);
}
return exposedHeaders;
},
/**
* @function _validateCredentials
* Guard against creating or update a bucket with invalid creds
* @param {object} credentials The body of the request
* @throws A conflict error problem if the bucket is not reachable
*/
async _validateCredentials(credentials) {
try {
const bucketSettings = {
accessKeyId: credentials.accessKeyId,
bucket: credentials.bucket,
endpoint: credentials.endpoint,
key: credentials.key ? credentials.key : '/',
region: credentials.region || DEFAULTREGION,
secretAccessKey: credentials.secretAccessKey,
};
await storageService.headBucket(bucketSettings);
} catch (e) {
// If it's caught here it's unable to validate the supplied store/bucket and creds
log.warn(`Failure to validate bucket credentials: ${e.message}`, {
function: '_validateCredentials',
});
throw new Problem(409, {
detail: 'Unable to validate supplied credentials for the bucket',
});
}
},
/**
* @function createBucket
* Creates a bucket
* @param {object} req Express request object
* @param {object} res Express response object
* @param {function} next The next callback function
* @returns {function} Express middleware function
* @throws The error encountered upon failure
*/
async createBucket(req, res, next) {
const data = {
...req.body,
endpoint: stripDelimit(req.body.endpoint),
key: req.body.key ? joinPath(stripDelimit(req.body.key)) : undefined
};
let response = undefined;
try {
// Check for credential accessibility/validity first
await controller._validateCredentials(data);
data.userId = await userService.getCurrentUserId(getCurrentIdentity(req.currentUser, SYSTEM_USER));
// if permCodes array (eg: ['READ', 'UPDATE'] or []) was provided use that (de-duped),
// otherwise assign all permissions
data.permCodes = req.body.permCodes ? Array.from(new Set(req.body.permCodes)) : Object.values(Permissions);
response = await bucketService.create(data);
} catch (e) {
// If bucket exists, check if credentials precisely match
if (e instanceof UniqueViolationError) {
// Grant permissions if credentials precisely match
response = await bucketService.checkGrantPermissions(data).catch(permErr => {
next(new Problem(403, { detail: permErr.message, instance: req.originalUrl }));
});
} else {
next(errorToProblem(SERVICE, e));
}
} finally {
if (response) res.status(201).json(redactSecrets(response, secretFields));
}
},
/**
* @function createBucketChild
* Creates a child bucket
* @param {object} req Express request object
* @param {object} res Express response object
* @param {function} next The next callback function
* @returns {function} Express middleware function
* @throws The error encountered upon failure
*/
async createBucketChild(req, res, next) {
try {
// Get Parent bucket data
const parentBucketId = addDashesToUuid(req.params.bucketId);
const parentBucket = await bucketService.read(parentBucketId);
// Check new child key length
const childKey = joinPath(stripDelimit(parentBucket.key), stripDelimit(req.body.subKey));
if (childKey.length > 255) {
throw new Problem(422, {
detail: 'New derived key exceeds maximum length of 255',
instance: req.originalUrl,
key: childKey
});
}
// Future task: give user MANAGE permission on existing sub-folder (bucket) instead (see above)
// Check for existing bucket collision
const bucketCollision = await bucketService.readUnique({
bucket: parentBucket.bucket,
endpoint: parentBucket.endpoint,
key: childKey
}).catch(() => undefined);
if (bucketCollision) {
throw new Problem(409, {
bucketId: bucketCollision.bucketId,
detail: 'Requested bucket already exists',
instance: req.originalUrl,
key: childKey
});
}
// Check for credential accessibility/validity
const childBucket = {
bucketName: req.body.bucketName,
accessKeyId: parentBucket.accessKeyId,
bucket: parentBucket.bucket,
endpoint: parentBucket.endpoint,
key: childKey,
secretAccessKey: parentBucket.secretAccessKey,
region: parentBucket.region ?? undefined,
active: parentBucket.active
};
await controller._validateCredentials(childBucket);
childBucket.userId = await userService.getCurrentUserId(getCurrentIdentity(req.currentUser, SYSTEM_USER));
// assign all permissions
childBucket.permCodes = Object.values(Permissions);
// Create child bucket
const response = await bucketService.create(childBucket);
res.status(201).json(redactSecrets(response, secretFields));
} catch (e) {
next(errorToProblem(SERVICE, e));
}
},
/**
* @function deleteBucket
* Deletes the bucket
* @param {object} req Express request object
* @param {object} res Express response object
* @param {function} next The next callback function
* @returns {function} Express middleware function
*/
async deleteBucket(req, res, next) {
try {
const bucketId = addDashesToUuid(req.params.bucketId);
await bucketService.delete(bucketId);
res.status(204).end();
} catch (e) {
next(errorToProblem(SERVICE, e));
}
},
/**
* @function headBucket
* Returns bucket headers
* @param {object} req Express request object
* @param {object} res Express response object
* @param {function} next The next callback function
* @returns {function} Express middleware function
*/
async headBucket(req, res, next) {
try {
const bucketId = addDashesToUuid(req.params.bucketId);
await storageService.headBucket({ bucketId });
res.status(204).end();
} catch (e) {
next(errorToProblem(SERVICE, e));
}
},
/**
* @function readBucket
* Returns a bucket
* @param {object} req Express request object
* @param {object} res Express response object
* @param {function} next The next callback function
* @returns {function} Express middleware function
*/
async readBucket(req, res, next) {
try {
const bucketId = addDashesToUuid(req.params.bucketId);
const response = await bucketService.read(bucketId);
res.status(200).json(redactSecrets(response, secretFields));
} catch (e) {
next(errorToProblem(SERVICE, e));
}
},
/**
* @function searchBuckets
* Search and filter for specific buckets
* @param {object} req Express request object
* @param {object} res Express response object
* @param {function} next The next callback function
* @returns {function} Express middleware function
*/
async searchBuckets(req, res, next) {
try {
const bucketIds = mixedQueryToArray(req.query.bucketId);
const params = {
bucketId: bucketIds ? bucketIds.map(id => addDashesToUuid(id)) : bucketIds,
bucketName: req.query.bucketName,
key: req.query.key,
active: isTruthy(req.query.active)
};
const response = await bucketService.searchBuckets(params);
res.status(200).json(response.map(bucket => redactSecrets(bucket, secretFields)));
} catch (error) {
next(error);
}
},
/**
* @function updateBucket
* Updates a bucket
* @param {object} req Express request object
* @param {object} res Express response object
* @param {function} next The next callback function
* @returns {function} Express middleware function
*/
async updateBucket(req, res, next) {
try {
const bucketId = addDashesToUuid(req.params.bucketId);
const currentBucket = await bucketService.read(bucketId);
// Check for credential accessibility/validity first
// Need to cross reference with existing data when partial patch data is provided
await controller._validateCredentials({
...currentBucket,
...req.body,
endpoint: req.body.endpoint ? stripDelimit(req.body.endpoint) : currentBucket.endpoint
});
const userId = await userService.getCurrentUserId(getCurrentIdentity(req.currentUser, SYSTEM_USER), SYSTEM_USER);
const response = await bucketService.update({
bucketId: bucketId,
bucketName: req.body.bucketName,
accessKeyId: req.body.accessKeyId,
bucket: req.body.bucket,
endpoint: req.body.endpoint ? stripDelimit(req.body.endpoint) : undefined,
secretAccessKey: req.body.secretAccessKey,
region: req.body.region,
active: isTruthy(req.body.active),
userId: userId
});
res.status(200).json(redactSecrets(response, secretFields));
} catch (e) {
next(errorToProblem(SERVICE, e));
}
}
};
module.exports = controller;