diff --git a/.env.example b/.env.example index bbeef070d..cf354ea1b 100644 --- a/.env.example +++ b/.env.example @@ -125,6 +125,9 @@ REDIS_PORT=6379 REDIS_HOST=localhost REDIS_PASSWORD= +# External repository access management +EXTERNAL_ACCESS_MANAGEMENT=false + # Cypress CYPRESS_USERNAME=admin1@example.com CYPRESS_PASSWORD=admin123. diff --git a/client/components/catalog/Card/Tags/index.vue b/client/components/catalog/Card/Tags/index.vue index 700f3d694..b574b91e8 100644 --- a/client/components/catalog/Card/Tags/index.vue +++ b/client/components/catalog/Card/Tags/index.vue @@ -2,19 +2,23 @@
{{ name }} diff --git a/client/components/catalog/Container.vue b/client/components/catalog/Container.vue index fce3773f7..6b1ea6e75 100644 --- a/client/components/catalog/Container.vue +++ b/client/components/catalog/Container.vue @@ -84,6 +84,7 @@ export default { name: 'catalog-container', data: () => ({ loading: true }), computed: { + isExternalAccessManagement: () => process.env.EXTERNAL_ACCESS_MANAGEMENT, ...mapState('repositories', { sortBy: state => state.$internals.sort, repositoryFilter: 'repositoryFilter', @@ -153,6 +154,9 @@ export default { } }, created() { + if (this.isExternalAccessManagement) { + this.$router.go(-1); + } // repositories must be reloaded for publishing badge to work properly // reset state manually to trigger "infinite" event in all cases this.resetPagination(); diff --git a/client/components/common/Navbar.vue b/client/components/common/Navbar.vue index a71eba922..c9cf9e513 100644 --- a/client/components/common/Navbar.vue +++ b/client/components/common/Navbar.vue @@ -63,11 +63,13 @@ export default { ...mapGetters('repository', ['repository']), title: () => BRAND_CONFIG.TITLE, logo: () => BRAND_CONFIG.LOGO_FULL, + isExternalAccessManagement: () => process.env.EXTERNAL_ACCESS_MANAGEMENT, routes() { const items = [ { name: 'Catalog', to: { name: 'catalog' } }, { name: 'Admin', to: { name: 'system-user-management' } } ]; + if (this.isExternalAccessManagement) items.shift(); if (!this.isAdmin) items.pop(); if (this.repository) { items.unshift({ diff --git a/client/components/repository/Settings/Sidebar.vue b/client/components/repository/Settings/Sidebar.vue index 5c020e677..00b6cdea9 100644 --- a/client/components/repository/Settings/Sidebar.vue +++ b/client/components/repository/Settings/Sidebar.vue @@ -43,20 +43,29 @@ export default { name: 'repository-settings-sidebar', computed: { + isExternalAccessManagement: () => process.env.EXTERNAL_ACCESS_MANAGEMENT, routes() { const { query } = this.$route; - return [ + const entries = [ { label: 'General', name: 'repository-info', icon: 'wrench' }, { label: 'People', name: 'user-management', icon: 'account' } ].map(route => ({ ...route, query })); + if (this.isExternalAccessManagement) entries.pop(); + return entries; }, actions() { - return [ - { label: 'Clone', icon: 'content-copy', name: 'clone' }, + const defaultEntries = [ { label: 'Publish', icon: 'upload', name: 'publish' }, { label: 'Export', icon: 'export', name: 'export' }, { label: 'Delete', icon: 'delete', name: 'delete', color: 'error' } ]; + const conditionalEntries = this.isExternalAccessManagement + ? [] + : [{ label: 'Clone', icon: 'content-copy', name: 'clone' }]; + return [ + ...defaultEntries, + ...conditionalEntries + ]; } } }; diff --git a/client/components/repository/Settings/UserManagement/index.vue b/client/components/repository/Settings/UserManagement/index.vue index 41a718d46..ba796a8b6 100644 --- a/client/components/repository/Settings/UserManagement/index.vue +++ b/client/components/repository/Settings/UserManagement/index.vue @@ -18,8 +18,12 @@ import UserList from './UserList.vue'; export default { name: 'repository-user-management', computed: { + isExternalAccessManagement: () => process.env.EXTERNAL_ACCESS_MANAGEMENT, roles: () => map(role.repository, value => ({ text: titleCase(value), value })) }, + created() { + if (this.isExternalAccessManagement) this.$router.go(-1); + }, components: { AddUserDialog, UserList diff --git a/client/components/system-settings/Sidebar.vue b/client/components/system-settings/Sidebar.vue index 4f90ca75d..b2662dd92 100644 --- a/client/components/system-settings/Sidebar.vue +++ b/client/components/system-settings/Sidebar.vue @@ -20,12 +20,15 @@ export default { name: 'system-settings-sidebar', computed: { + isExternalAccessManagement: () => process.env.EXTERNAL_ACCESS_MANAGEMENT, routes() { - return [ + const items = [ { label: 'System Users', name: 'system-user-management', icon: 'account' }, { label: 'Structure Types', name: 'installed-schemas', icon: 'file-tree' }, { label: 'Installed Elements', name: 'installed-elements', icon: 'puzzle' } ]; + if (this.isExternalAccessManagement) items.shift(); + return items; } } }; diff --git a/client/components/system-settings/UserManagement/index.vue b/client/components/system-settings/UserManagement/index.vue index 5c027305f..750f6408f 100644 --- a/client/components/system-settings/UserManagement/index.vue +++ b/client/components/system-settings/UserManagement/index.vue @@ -123,6 +123,7 @@ export default { }, computed: { ...mapState({ user: state => state.auth.user }), + isExternalAccessManagement: () => process.env.EXTERNAL_ACCESS_MANAGEMENT, headers, defaultPage }, @@ -166,6 +167,9 @@ export default { this.fetch(); } }, + created() { + if (this.isExternalAccessManagement) this.$router.go(-1); + }, components: { UserDialog } }; diff --git a/config/server/index.js b/config/server/index.js index a14d9b197..0cfec2271 100644 --- a/config/server/index.js +++ b/config/server/index.js @@ -6,12 +6,14 @@ import * as store from './store.js'; import * as tce from './tce.js'; import isLocalhost from 'is-localhost'; import parse from 'url-parse'; +import yn from 'yn'; const hostname = resolveHostname(); const protocol = resolveProtocol(hostname); const port = resolvePort(); const origin = resolveOrigin(hostname, protocol, port); const previewUrl = process.env.PREVIEW_URL; +const isExternalAccessManagement = yn(process.env.EXTERNAL_ACCESS_MANAGEMENT); // Legacy config support function resolveHostname() { @@ -54,7 +56,8 @@ export { previewUrl, consumer, store, - tce + tce, + isExternalAccessManagement }; export default { @@ -68,5 +71,6 @@ export default { previewUrl, consumer, store, - tce + tce, + isExternalAccessManagement }; diff --git a/config/shared/role.js b/config/shared/role.js index 6436c22db..4343c48e2 100644 --- a/config/shared/role.js +++ b/config/shared/role.js @@ -1,7 +1,7 @@ import values from 'lodash/values.js'; const role = { - user: { USER: 'USER', ADMIN: 'ADMIN' }, + user: { USER: 'USER', ADMIN: 'ADMIN', INTEGRATION: 'INTEGRATION' }, repository: { ADMIN: 'ADMIN', AUTHOR: 'AUTHOR' } }; diff --git a/server/repository/index.js b/server/repository/index.js index e7ef16ca0..0285edec5 100644 --- a/server/repository/index.js +++ b/server/repository/index.js @@ -1,15 +1,17 @@ +import { authorize, authorizeIntegration } from '../shared/auth/mw.js'; import { NOT_FOUND, UNAUTHORIZED } from 'http-status-codes'; -import { authorize } from '../shared/auth/mw.js'; import { createError } from '../shared/error/helpers.js'; import ctrl from './repository.controller.js'; import db from '../shared/database/index.js'; import express from 'express'; import feed from './feed/index.js'; +import { isExternalAccessManagement } from '../../config/server/index.js'; import multer from 'multer'; import path from 'node:path'; import processQuery from '../shared/util/processListQuery.js'; import proxy from './proxy.js'; import proxyMw from '../shared/storage/proxy/mw.js'; +import roleConfig from '../../config/shared/role.js'; import storage from './storage.js'; /* eslint-disable */ @@ -17,19 +19,24 @@ import activity from '../activity/index.js'; import comment from '../comment/index.js'; import revision from '../revision/index.js'; import contentElement from '../content-element/index.js'; +const { user: role } = roleConfig; import storageRouter from '../shared/storage/storage.router.js'; /* eslint-enable */ -const { Repository } = db; +const { Repository, Tag } = db; const router = express.Router(); const { setSignedCookies } = proxyMw(storage, proxy); +const authorizeUser = isExternalAccessManagement + ? authorizeIntegration + : authorize(); + // NOTE: disk storage engine expects an object to be passed as the first argument // https://github.com/expressjs/multer/blob/6b5fff5/storage/disk.js#L17-L18 const upload = multer({ storage: multer.diskStorage({}) }); router - .post('/import', authorize(), upload.single('archive'), ctrl.import); + .post('/import', authorizeUser, upload.single('archive'), ctrl.import); router .param('repositoryId', getRepository) @@ -37,7 +44,7 @@ router router.route('/') .get(processQuery({ limit: 100 }), ctrl.index) - .post(authorize(), ctrl.create); + .post(authorizeUser, ctrl.create); router.route('/:repositoryId') .get(ctrl.get) @@ -46,15 +53,15 @@ router.route('/:repositoryId') router .post('/:repositoryId/pin', ctrl.pin) - .post('/:repositoryId/clone', authorize(), ctrl.clone) + .post('/:repositoryId/clone', authorizeUser, ctrl.clone) .post('/:repositoryId/publish', ctrl.publishRepoInfo) .get('/:repositoryId/users', ctrl.getUsers) .get('/:repositoryId/export/setup', ctrl.initiateExportJob) .post('/:repositoryId/export/:jobId', ctrl.export) - .post('/:repositoryId/users', ctrl.upsertUser) - .delete('/:repositoryId/users/:userId', ctrl.removeUser) - .post('/:repositoryId/tags', ctrl.addTag) - .delete('/:repositoryId/tags/:tagId', ctrl.removeTag); + .post('/:repositoryId/users', authorizeUser, ctrl.upsertUser) + .delete('/:repositoryId/users/:userId', authorizeUser, ctrl.removeUser) + .post('/:repositoryId/tags', authorizeUser, ctrl.addTag) + .delete('/:repositoryId/tags/:tagId', authorizeUser, ctrl.removeTag); mount(router, '/:repositoryId', feed); mount(router, '/:repositoryId', activity); @@ -68,7 +75,8 @@ function mount(router, mountPath, subrouter) { } function getRepository(req, _res, next, repositoryId) { - return Repository.findByPk(repositoryId, { paranoid: false }) + return Repository + .findByPk(repositoryId, { include: [{ model: Tag }], paranoid: false }) .then(repository => repository || createError(NOT_FOUND, 'Repository not found')) .then(repository => { req.repository = repository; @@ -79,6 +87,13 @@ function getRepository(req, _res, next, repositoryId) { function hasAccess(req, _res, next) { const { user, repository } = req; if (user.isAdmin()) return next(); + const repositoryTagIds = repository.tags + ?.filter(it => it.isAccessTag) + .map(it => it.id); + if (repositoryTagIds.length && user.isAssociatedWithSomeTag(repositoryTagIds)) { + req.repositoryRole = role.ADMIN; + return next(); + } return repository.getUser(user) .then(user => user || createError(UNAUTHORIZED, 'Access restricted')) .then(user => { diff --git a/server/repository/repository.controller.js b/server/repository/repository.controller.js index 8a40899bf..8fa40d794 100644 --- a/server/repository/repository.controller.js +++ b/server/repository/repository.controller.js @@ -1,6 +1,7 @@ import * as fs from 'node:fs'; import * as fsp from 'node:fs/promises'; -import { NO_CONTENT, NOT_FOUND } from 'http-status-codes'; +import { FORBIDDEN, NO_CONTENT, NOT_FOUND } from 'http-status-codes'; +import { repository as role, user as userRole } from '../../config/shared/role.js'; import { createError } from '../shared/error/helpers.js'; import db from '../shared/database/index.js'; import getVal from 'lodash/get.js'; @@ -9,7 +10,6 @@ import { Op } from 'sequelize'; import pick from 'lodash/pick.js'; import Promise from 'bluebird'; import publishingService from '../shared/publishing/publishing.service.js'; -import { repository as role } from '../../config/shared/role.js'; import sample from 'lodash/sample.js'; import { schema } from '../../config/shared/tailor.loader.js'; import { snakeCase } from 'change-case'; @@ -171,15 +171,27 @@ function findOrCreateRole(repository, user, role) { .then(() => user); } -function addTag({ body: { name }, repository }, res) { +function addTag( + { + body: { name, isAccessTag = false }, + user, + repository + }, + res +) { return sequelize.transaction(async transaction => { - const [tag] = await Tag.findOrCreate({ where: { name }, transaction }); + const tag = await Tag.fetchOrCreate({ user, name, isAccessTag, transaction }); await repository.addTags([tag], { transaction }); return res.json({ data: tag }); }); } -async function removeTag({ params: { tagId, repositoryId } }, res) { +async function removeTag({ user, params: { tagId, repositoryId } }, res) { + const tag = await Tag.findByPk(tagId); + if (!tag) return createError(NOT_FOUND, 'Tag not found'); + if (tag.isAccessTag && user.role !== userRole.INTEGRATION) { + return createError(FORBIDDEN, 'Can be removed only by integration users'); + } const where = { tagId, repositoryId }; await RepositoryTag.destroy({ where }); return res.status(NO_CONTENT).send(); @@ -222,8 +234,8 @@ function importRepository({ body, file, user }, res) { return TransferService .createImportJob(path, options) .toPromise() - .finally(() => { - fs.unlink(path); + .finally(async () => { + await fsp.unlink(path); res.end(); }); } diff --git a/server/shared/auth/index.js b/server/shared/auth/index.js index a1a8e6931..411948e23 100644 --- a/server/shared/auth/index.js +++ b/server/shared/auth/index.js @@ -16,7 +16,9 @@ const options = { }; auth.use(new LocalStrategy(options, (email, password, done) => { - return User.unscoped().findOne({ where: { email } }) + return User + .unscoped() + .findOne({ where: { email } }) .then(user => user && user.authenticate(password)) .then(user => done(null, user || false)) .error(err => done(err, false)); @@ -27,7 +29,8 @@ auth.use(new JwtStrategy({ audience: Audience.Scope.Access, jwtFromRequest: ExtractJwt.fromExtractors([ extractJwtFromCookie, - ExtractJwt.fromBodyField('token') + ExtractJwt.fromBodyField('token'), + ExtractJwt.fromHeader('access_token') ]), secretOrKey: config.jwt.secret }, verifyJWT)); @@ -50,7 +53,7 @@ auth.deserializeUser((user, done) => done(null, user)); export default auth; function verifyJWT(payload, done) { - return User.unscoped().findByPk(payload.id) + return User.unscoped().findByPk(payload.id, { include: ['userTags'] }) .then(user => done(null, user || false)) .error(err => done(err, false)); } diff --git a/server/shared/auth/mw.js b/server/shared/auth/mw.js index 9e704f90a..de196c23f 100644 --- a/server/shared/auth/mw.js +++ b/server/shared/auth/mw.js @@ -14,6 +14,11 @@ function authorize(...allowed) { }; } +function authorizeIntegration({ user }, res, next) { + if (user && user.role === role.INTEGRATION) return next(); + return createError(UNAUTHORIZED, 'Access restricted'); +} + function extractAuthData(req, res, next) { const path = authConfig.jwt.cookie.signed ? 'signedCookies' : 'cookies'; req.authData = get(req[path], 'auth', null); @@ -22,5 +27,6 @@ function extractAuthData(req, res, next) { export { authorize, + authorizeIntegration, extractAuthData }; diff --git a/server/shared/database/index.js b/server/shared/database/index.js index f12650a24..946ecb96a 100644 --- a/server/shared/database/index.js +++ b/server/shared/database/index.js @@ -16,6 +16,7 @@ import { wrapMethods } from './helpers.js'; // Require models. /* eslint-disable */ import User from '../../user/user.model.js'; +import UserTag from '../../user/userTag.model.js'; import Repository from '../../repository/repository.model.js'; import RepositoryTag from '../../tag/repositoryTag.model.js'; import RepositoryUser from '../../repository/repositoryUser.model.js'; @@ -79,6 +80,7 @@ function initialize() { */ const models = { User: defineModel(User), + UserTag: defineModel(UserTag), Repository: defineModel(Repository), RepositoryTag: defineModel(RepositoryTag), RepositoryUser: defineModel(RepositoryUser), diff --git a/server/shared/database/migrations/20210204115516-create-user-tag.js b/server/shared/database/migrations/20210204115516-create-user-tag.js new file mode 100644 index 000000000..bb9bd708f --- /dev/null +++ b/server/shared/database/migrations/20210204115516-create-user-tag.js @@ -0,0 +1,28 @@ +'use strict'; + +const TABLE_NAME = 'user_tag'; + +exports.up = (queryInterface, Sequelize) => { + return queryInterface.createTable(TABLE_NAME, { + tagId: { + type: Sequelize.INTEGER, + field: 'tag_id', + references: { model: 'tag', key: 'id' }, + onDelete: 'CASCADE' + }, + userId: { + type: Sequelize.INTEGER, + field: 'user_id', + references: { model: 'user', key: 'id' }, + onDelete: 'CASCADE' + } + }).then(async () => { + return queryInterface.addConstraint(TABLE_NAME, { + name: 'user_tag_pkey', + type: 'primary key', + fields: ['user_id', 'tag_id'] + }); + }); +}; + +exports.down = queryInterface => queryInterface.dropTable(TABLE_NAME); diff --git a/server/shared/database/migrations/20210204115517-add-access-flag-to-tag.js b/server/shared/database/migrations/20210204115517-add-access-flag-to-tag.js new file mode 100644 index 000000000..ba03925b4 --- /dev/null +++ b/server/shared/database/migrations/20210204115517-add-access-flag-to-tag.js @@ -0,0 +1,9 @@ +'use strict'; + +const TABLE_NAME = 'tag'; +const COLUMN_NAME = 'is_access_tag'; + +exports.up = (qi, { BOOLEAN }) => + qi.addColumn(TABLE_NAME, COLUMN_NAME, { type: BOOLEAN, defaultValue: false }); + +exports.down = qi => qi.removeColumn(TABLE_NAME, COLUMN_NAME); diff --git a/server/tag/index.js b/server/tag/index.js index 90257d38c..2133be6a0 100644 --- a/server/tag/index.js +++ b/server/tag/index.js @@ -1,10 +1,35 @@ +import { authorizeIntegration } from '../shared/auth/mw.js'; +import { createError } from '../shared/error/helpers.js'; import ctrl from './tag.controller.js'; +import db from '../shared/database/index.js'; import express from 'express'; +import { NOT_FOUND } from 'http-status-codes'; const router = express.Router(); +const { Tag } = db; router - .get('/', ctrl.list); + .get('/', ctrl.list) + .post('/', authorizeIntegration, ctrl.create); + +router + .param('tagId', getTag) + .use('/:tagId', authorizeIntegration); + +router.route('/:tagId') + .get(ctrl.get) + .patch(ctrl.patch) + .delete(ctrl.remove); + +function getTag(req, _res, next, tagId) { + return Tag + .findByPk(tagId) + .then(tag => tag || createError(NOT_FOUND, 'Tag not found')) + .then(tag => { + req.tag = tag; + next(); + }); +} export default { path: '/tags', diff --git a/server/tag/tag.controller.js b/server/tag/tag.controller.js index 9b93f3d93..915943332 100644 --- a/server/tag/tag.controller.js +++ b/server/tag/tag.controller.js @@ -1,4 +1,7 @@ +import { BAD_REQUEST, NO_CONTENT } from 'http-status-codes'; +import { createError } from '../shared/error/helpers.js'; import db from '../shared/database/index.js'; +import pick from 'lodash/pick.js'; import yn from 'yn'; const { Tag } = db; @@ -10,6 +13,36 @@ async function list({ user, query: { associated } }, res) { return res.json({ data: tags }); } +async function get({ tag }, res) { + return res.json({ data: tag }); +} + +function create({ body }, res) { + const attrs = ['name', 'isAccessTag']; + const payload = pick(body, attrs); + return Tag.create(payload) + .then(data => res.json({ data })); +} + +function patch({ tag, body }, res) { + if (Object.hasOwn(body, 'isAccessTag')) { + return createError(BAD_REQUEST, 'isAccessTag cannot be updated!'); + } + const { name } = body; + return tag.update({ name }) + .then(tag => tag.reload()) + .then(data => res.json({ data })); +} + +function remove({ tag }, res) { + return tag.destroy() + .then(() => res.status(NO_CONTENT).end()); +} + export default { - list + list, + get, + create, + patch, + remove }; diff --git a/server/tag/tag.model.js b/server/tag/tag.model.js index b2997ff28..067458eb6 100644 --- a/server/tag/tag.model.js +++ b/server/tag/tag.model.js @@ -1,7 +1,8 @@ import { Model } from 'sequelize'; +import { user as userRole } from '../../config/shared/role.js'; class Tag extends Model { - static fields({ STRING, UUID, UUIDV4 }) { + static fields({ BOOLEAN, STRING, UUID, UUIDV4 }) { return { uid: { type: UUID, @@ -14,15 +15,34 @@ class Tag extends Model { allowNull: false, unique: true, validate: { notEmpty: true, len: [2, 20] } + }, + isAccessTag: { + type: BOOLEAN, + field: 'is_access_tag', + defaultValue: false } }; } - static associate({ Repository, RepositoryTag }) { + static associate({ Repository, RepositoryTag, User, UserTag }) { this.belongsToMany(Repository, { through: RepositoryTag, foreignKey: { name: 'tagId', field: 'tag_id' } }); + this.belongsToMany(User, { + through: UserTag, + foreignKey: { name: 'tagId', field: 'tag_id' } + }); + } + + static async fetchOrCreate({ user, name, isAccessTag = false, transaction }) { + if (isAccessTag && user.role !== userRole.INTEGRATION) { + throw new Error('Only integration user can create access tags'); + } + const tag = await Tag.findOne({ where: { name }, transaction }); + if (!tag) return this.create({ name, isAccessTag }, { transaction }); + if (tag && tag.isAccessTag === isAccessTag) return tag; + throw new Error('Cannot change tag type'); } static getAssociated(user) { @@ -36,9 +56,15 @@ class Tag extends Model { required: true }; if (user && !user.isAdmin()) { - includeRepository.include = [{ model: User, attributes: ['id'], where: { id: user.id } }]; + includeRepository.include = [{ + model: User, + attributes: ['id'], + where: { id: user.id } + }]; } - return Tag.findAll({ include: [includeRepository] }); + return Tag.findAll({ + include: [includeRepository] + }); } static options() { diff --git a/server/user/index.js b/server/user/index.js index 195d481d0..cc9535534 100644 --- a/server/user/index.js +++ b/server/user/index.js @@ -1,16 +1,21 @@ +import { authorize, authorizeIntegration } from '../shared/auth/mw.js'; import { loginRequestLimiter, resetLoginAttempts, setLoginLimitKey } from './mw.js'; import { ACCEPTED } from 'http-status-codes'; -import { authorize } from '../shared/auth/mw.js'; import authService from '../shared/auth/index.js'; import ctrl from './user.controller.js'; import db from '../shared/database/index.js'; import express from 'express'; +import { isExternalAccessManagement } from '../../config/server/index.js'; import { processPagination } from '../shared/database/pagination.js'; import { requestLimiter } from '../shared/request/mw.js'; const { User } = db; const router = express.Router(); +const authorizeUser = isExternalAccessManagement + ? authorizeIntegration + : authorize(); + // Public routes: router .post( @@ -29,14 +34,16 @@ router // Protected routes: router .use(authService.authenticate('jwt')) - .get('/', authorize(), processPagination(User), ctrl.list) - .post('/', authorize(), ctrl.upsert) + .get('/', authorizeUser, processPagination(User), ctrl.list) + .post('/', authorizeUser, ctrl.upsert) .get('/logout', authService.logout()) .get('/me', ctrl.getProfile) .patch('/me', ctrl.updateProfile) .post('/me/change-password', ctrl.changePassword) - .delete('/:id', authorize(), ctrl.remove) - .post('/:id/reinvite', authorize(), ctrl.reinvite); + .delete('/:id', authorizeUser, ctrl.remove) + .post('/:id/reinvite', authorizeUser, ctrl.reinvite) + .post('/:id/tag', authorizeUser, ctrl.addTag) + .delete('/:id/tag/:tagId', authorizeUser, ctrl.removeTag); export default { path: '/users', diff --git a/server/user/user.controller.js b/server/user/user.controller.js index 1af261c1b..7a8b738db 100644 --- a/server/user/user.controller.js +++ b/server/user/user.controller.js @@ -1,10 +1,11 @@ -import { ACCEPTED, BAD_REQUEST, CONFLICT, NO_CONTENT, NOT_FOUND } from 'http-status-codes'; +import { ACCEPTED, BAD_REQUEST, CONFLICT, FORBIDDEN, NO_CONTENT, NOT_FOUND } from 'http-status-codes'; import { createError, validationError } from '../shared/error/helpers.js'; import db from '../shared/database/index.js'; import map from 'lodash/map.js'; import { Op } from 'sequelize'; +import { user as userRole } from '../../config/shared/role.js'; -const { User } = db; +const { sequelize, Tag, User, UserTag } = db; const createFilter = q => map(['email', 'firstName', 'lastName'], it => ({ [it]: { [Op.iLike]: `%${q}%` } })); @@ -69,6 +70,36 @@ function reinvite({ params }, res) { .then(() => res.status(ACCEPTED).end()); } +function addTag( + { + user, + body: { name, isAccessTag }, + params: { id: userId } + }, + res +) { + return sequelize.transaction(async transaction => { + const tagUser = await User.findByPk(userId, { transaction }); + if (!tagUser) return createError(NOT_FOUND, 'User not found'); + const tag = await Tag.fetchOrCreate({ user, name, isAccessTag, transaction }); + await tagUser.addTags([tag], { transaction }); + return res.json({ data: tag }); + }); +} + +async function removeTag({ user, params: { tagId, id: userId } }, res) { + const tagUser = await User.findByPk(userId); + if (!tagUser) return createError(NOT_FOUND, 'User not found'); + const tag = await Tag.findByPk(tagId); + if (!tag) return createError(NOT_FOUND, 'Tag not found'); + if (tag.isAccessTag && user.role !== userRole.INTEGRATION) { + return res.status(FORBIDDEN, 'Only integration users can remove access tags'); + } + const where = { tagId, userId }; + await UserTag.destroy({ where }); + return res.status(NO_CONTENT).send(); +} + export default { list, upsert, @@ -78,5 +109,7 @@ export default { getProfile, updateProfile, changePassword, - reinvite + reinvite, + addTag, + removeTag }; diff --git a/server/user/user.model.js b/server/user/user.model.js index a5f6fc23f..629242c64 100644 --- a/server/user/user.model.js +++ b/server/user/user.model.js @@ -95,7 +95,14 @@ class User extends Model { }; } - static associate({ ActivityStatus, Comment, Repository, RepositoryUser }) { + static associate({ + ActivityStatus, + Comment, + Repository, + RepositoryUser, + Tag, + UserTag + }) { this.hasMany(Comment, { foreignKey: { name: 'authorId', field: 'author_id' } }); @@ -103,6 +110,14 @@ class User extends Model { through: RepositoryUser, foreignKey: { name: 'userId', field: 'user_id' } }); + this.belongsToMany(Tag, { + through: UserTag, + foreignKey: { name: 'userId', field: 'user_id' } + }); + this.hasMany(UserTag, { + as: 'userTags', + foreignKey: { name: 'userId', field: 'user_id' } + }); this.hasMany(ActivityStatus, { as: 'assignedActivities', foreignKey: { name: 'assigneeId', field: 'assignee_id' } @@ -214,6 +229,10 @@ class User extends Model { if (audience === Audience.Scope.Access) return secret; return [secret, this.password, this.createdAt.getTime()].join(''); } + + isAssociatedWithSomeTag(tagIds) { + return this.userTags.some(({ tagId }) => tagIds.includes(tagId)); + } } export default User; diff --git a/server/user/userTag.model.js b/server/user/userTag.model.js new file mode 100644 index 000000000..5a46b4402 --- /dev/null +++ b/server/user/userTag.model.js @@ -0,0 +1,40 @@ +import { Model } from 'sequelize'; + +class UserTag extends Model { + static fields({ INTEGER }) { + return ({ + userId: { + type: INTEGER, + field: 'user_id', + primaryKey: true, + unique: 'user_tag_pkey' + }, + tagId: { + type: INTEGER, + field: 'tag_id', + primaryKey: true, + unique: 'user_tag_pkey' + } + }); + } + + static associate({ User, Tag }) { + this.belongsTo(User, { + foreignKey: { name: 'userId', field: 'user_id' } + }); + this.belongsTo(Tag, { + foreignKey: { name: 'tagId', field: 'tag_id' } + }); + } + + static options() { + return ({ + modelName: 'UserTag', + tableName: 'user_tag', + underscored: true, + timestamps: false + }); + } +} + +export default UserTag; diff --git a/tools/integration-user/README.md b/tools/integration-user/README.md new file mode 100644 index 000000000..73949b822 --- /dev/null +++ b/tools/integration-user/README.md @@ -0,0 +1,25 @@ +# Integration API + +## Add integration user + +To add integration user run: + +```shell +npm run integration:add +``` + +to generate a integration token run: + +```shell +npm run integration:token +``` + +Use generated token to update `access_token` global variable for the provided +Postman collection. Also make sure that the `base_url` variable is properly set. + +## External access management + +To disable the ability for users to create repositories and manage access rights +set `EXTERNAL_ACCESS_MANAGEMENT` .env variable to true. This will lock +repository catalog UI access, remove the ability to clone repositories, and +remove user management UI. diff --git a/tools/integration-user/integration_user_API.postman_collection.json b/tools/integration-user/integration_user_API.postman_collection.json new file mode 100644 index 000000000..0db703761 --- /dev/null +++ b/tools/integration-user/integration_user_API.postman_collection.json @@ -0,0 +1,962 @@ +{ + "info": { + "_postman_id": "d2543cae-4168-45ba-bb48-3d4efc4f0d9f", + "name": "Tailor integration user API", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "31477572" + }, + "item": [ + { + "name": "repository", + "item": [ + { + "name": "user", + "item": [ + { + "name": "Get repository users", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "apikey", + "apikey": [ + { + "key": "value", + "value": "{{access_token}}", + "type": "string" + }, + { + "key": "key", + "value": "access_token", + "type": "string" + }, + { + "key": "in", + "value": "header", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/repositories/:id/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "repositories", + ":id", + "users" + ], + "variable": [ + { + "key": "id", + "value": "1" + } + ] + }, + "description": "This is a GET request and it is used to \"get\" data from an endpoint. There is no request body for a GET request, but you can use query parameters to help specify the resource you want data on (e.g., in this request, we have `id=1`).\n\nA successful GET response will have a `200 OK` status, and should include some kind of response body - for example, HTML web content or JSON data." + }, + "response": [] + }, + { + "name": "Enable repository access", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "apikey", + "apikey": [ + { + "key": "value", + "value": "{{access_token}}", + "type": "string" + }, + { + "key": "key", + "value": "access_token", + "type": "string" + }, + { + "key": "in", + "value": "header", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"admin@example.com\",\n \"role\": \"ADMIN\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/repositories/:id/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "repositories", + ":id", + "users" + ], + "variable": [ + { + "key": "id", + "value": "1" + } + ] + }, + "description": "This is a GET request and it is used to \"get\" data from an endpoint. There is no request body for a GET request, but you can use query parameters to help specify the resource you want data on (e.g., in this request, we have `id=1`).\n\nA successful GET response will have a `200 OK` status, and should include some kind of response body - for example, HTML web content or JSON data." + }, + "response": [] + }, + { + "name": "Revoke repository access", + "request": { + "auth": { + "type": "apikey", + "apikey": [ + { + "key": "value", + "value": "{{access_token}}", + "type": "string" + }, + { + "key": "key", + "value": "access_token", + "type": "string" + }, + { + "key": "in", + "value": "header", + "type": "string" + } + ] + }, + "method": "DELETE", + "header": [], + "url": { + "raw": "{{base_url}}/repositories/:id/users/:userId", + "host": [ + "{{base_url}}" + ], + "path": [ + "repositories", + ":id", + "users", + ":userId" + ], + "variable": [ + { + "key": "id", + "value": "1" + }, + { + "key": "userId", + "value": "1" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "tag", + "item": [ + { + "name": "Add tag", + "request": { + "auth": { + "type": "apikey", + "apikey": [ + { + "key": "value", + "value": "{{access_token}}", + "type": "string" + }, + { + "key": "key", + "value": "access_token", + "type": "string" + }, + { + "key": "in", + "value": "header", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Test\",\n \"isAccessTag\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/repositories/:id/tags", + "host": [ + "{{base_url}}" + ], + "path": [ + "repositories", + ":id", + "tags" + ], + "variable": [ + { + "key": "id", + "value": "1" + } + ] + } + }, + "response": [] + }, + { + "name": "Remove tag", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{base_url}}/repositories/:id/tags/:tagId", + "host": [ + "{{base_url}}" + ], + "path": [ + "repositories", + ":id", + "tags", + ":tagId" + ], + "variable": [ + { + "key": "id", + "value": "" + }, + { + "key": "tagId", + "value": "" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "List repositories", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/info?id=1", + "host": [ + "{{base_url}}" + ], + "path": [ + "info" + ], + "query": [ + { + "key": "id", + "value": "1" + } + ] + }, + "description": "This is a GET request and it is used to \"get\" data from an endpoint. There is no request body for a GET request, but you can use query parameters to help specify the resource you want data on (e.g., in this request, we have `id=1`).\n\nA successful GET response will have a `200 OK` status, and should include some kind of response body - for example, HTML web content or JSON data." + }, + "response": [] + }, + { + "name": "List repositories with filter and pagination", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "apikey", + "apikey": [ + { + "key": "value", + "value": "{{access_token}}", + "type": "string" + }, + { + "key": "key", + "value": "access_token", + "type": "string" + }, + { + "key": "in", + "value": "header", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/repositories?search=te&offset=0&limit=21&sortOrder=DESC&sortBy=createdAt&tagIds[]=1", + "host": [ + "{{base_url}}" + ], + "path": [ + "repositories" + ], + "query": [ + { + "key": "search", + "value": "te" + }, + { + "key": "offset", + "value": "0" + }, + { + "key": "limit", + "value": "21" + }, + { + "key": "sortOrder", + "value": "DESC" + }, + { + "key": "sortBy", + "value": "createdAt" + }, + { + "key": "tagIds[]", + "value": "1" + } + ] + }, + "description": "This is a GET request and it is used to \"get\" data from an endpoint. There is no request body for a GET request, but you can use query parameters to help specify the resource you want data on (e.g., in this request, we have `id=1`).\n\nA successful GET response will have a `200 OK` status, and should include some kind of response body - for example, HTML web content or JSON data." + }, + "response": [] + }, + { + "name": "Get repository", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/info?id=1", + "host": [ + "{{base_url}}" + ], + "path": [ + "info" + ], + "query": [ + { + "key": "id", + "value": "1" + } + ] + }, + "description": "This is a GET request and it is used to \"get\" data from an endpoint. There is no request body for a GET request, but you can use query parameters to help specify the resource you want data on (e.g., in this request, we have `id=1`).\n\nA successful GET response will have a `200 OK` status, and should include some kind of response body - for example, HTML web content or JSON data." + }, + "response": [] + }, + { + "name": "Create repository", + "request": { + "auth": { + "type": "apikey", + "apikey": [ + { + "key": "value", + "value": "{{access_token}}", + "type": "string" + }, + { + "key": "key", + "value": "access_token", + "type": "string" + }, + { + "key": "in", + "value": "header", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Test\",\n \"description\": \"Test\",\n \"schema\": \"TEST_SCHEMA\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/repositories", + "host": [ + "{{base_url}}" + ], + "path": [ + "repositories" + ] + } + }, + "response": [] + }, + { + "name": "Clone repository", + "request": { + "auth": { + "type": "apikey", + "apikey": [ + { + "key": "value", + "value": "{{access_token}}", + "type": "string" + }, + { + "key": "key", + "value": "access_token", + "type": "string" + }, + { + "key": "in", + "value": "header", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Test clone\",\n \"description\": \"Test clone\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/repositories/:id/clone", + "host": [ + "{{base_url}}" + ], + "path": [ + "repositories", + ":id", + "clone" + ], + "variable": [ + { + "key": "id", + "value": "1" + } + ] + } + }, + "response": [] + }, + { + "name": "Import repository", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "archive", + "type": "file", + "src": "/Users/underscope/Desktop/test123.tgz" + }, + { + "key": "name", + "value": "Test import", + "type": "text" + }, + { + "key": "description", + "value": "Test import", + "type": "text" + } + ] + }, + "url": { + "raw": "{{base_url}}/repositories/import", + "host": [ + "{{base_url}}" + ], + "path": [ + "repositories", + "import" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "tag", + "item": [ + { + "name": "List", + "request": { + "method": "GET", + "header": [] + }, + "response": [] + }, + { + "name": "Get", + "request": { + "method": "GET", + "header": [] + }, + "response": [] + }, + { + "name": "Create", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Test update\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/tags", + "host": [ + "{{base_url}}" + ], + "path": [ + "tags" + ] + } + }, + "response": [] + }, + { + "name": "Update", + "request": { + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Test update\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/tags/:tagId", + "host": [ + "{{base_url}}" + ], + "path": [ + "tags", + ":tagId" + ], + "variable": [ + { + "key": "tagId", + "value": "1" + } + ] + } + }, + "response": [] + }, + { + "name": "Remove", + "request": { + "method": "DELETE", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Test update\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/tags/:tagId", + "host": [ + "{{base_url}}" + ], + "path": [ + "tags", + ":tagId" + ], + "variable": [ + { + "key": "tagId", + "value": "1" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "user", + "item": [ + { + "name": "List", + "request": { + "method": "GET", + "header": [] + }, + "response": [] + }, + { + "name": "Upsert", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"test@gostudion.com\",\n \"firstName\": \"test\",\n \"lastName\": \"test\",\n \"role\": \"USER\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/users", + "host": [ + "{{base_url}}" + ], + "path": [ + "users" + ] + } + }, + "response": [] + }, + { + "name": "Delete", + "request": { + "method": "DELETE", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"test@gostudion.com\",\n \"firstName\": \"test\",\n \"lastName\": \"test\",\n \"role\": \"USER\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/users/:id", + "host": [ + "{{base_url}}" + ], + "path": [ + "users", + ":id" + ], + "variable": [ + { + "key": "id", + "value": "1" + } + ] + } + }, + "response": [] + }, + { + "name": "Reinvite", + "request": { + "auth": { + "type": "apikey", + "apikey": [ + { + "key": "value", + "value": "{{access_token}}", + "type": "string" + }, + { + "key": "key", + "value": "access_token", + "type": "string" + }, + { + "key": "in", + "value": "header", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"test@gostudion.com\",\n \"firstName\": \"test\",\n \"lastName\": \"test\",\n \"role\": \"USER\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/users/:id/reinvite", + "host": [ + "{{base_url}}" + ], + "path": [ + "users", + ":id", + "reinvite" + ], + "variable": [ + { + "key": "id", + "value": "1" + } + ] + } + }, + "response": [] + }, + { + "name": "Tag user", + "request": { + "auth": { + "type": "apikey", + "apikey": [ + { + "key": "value", + "value": "{{access_token}}", + "type": "string" + }, + { + "key": "key", + "value": "access_token", + "type": "string" + }, + { + "key": "in", + "value": "header", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Test\",\n \"isAccessTag\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/users/:id/tag", + "host": [ + "{{base_url}}" + ], + "path": [ + "users", + ":id", + "tag" + ], + "variable": [ + { + "key": "id", + "value": "1" + } + ] + } + }, + "response": [] + }, + { + "name": "Remove tag", + "request": { + "auth": { + "type": "apikey", + "apikey": [ + { + "key": "value", + "value": "{{access_token}}", + "type": "string" + }, + { + "key": "key", + "value": "access_token", + "type": "string" + }, + { + "key": "in", + "value": "header", + "type": "string" + } + ] + }, + "method": "DELETE", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"test@gostudion.com\",\n \"firstName\": \"test\",\n \"lastName\": \"test\",\n \"role\": \"USER\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/users/:id/tag/:tagId", + "host": [ + "{{base_url}}" + ], + "path": [ + "users", + ":id", + "tag", + ":tagId" + ], + "variable": [ + { + "key": "id", + "value": "1" + }, + { + "key": "tagId", + "value": "4" + } + ] + } + }, + "response": [] + } + ] + } + ], + "auth": { + "type": "apikey", + "apikey": [ + { + "key": "value", + "value": "{{access_token}}", + "type": "string" + }, + { + "key": "key", + "value": "access_token", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ] +} \ No newline at end of file diff --git a/vite.config.mjs b/vite.config.mjs index 6b740ef42..1c4301cce 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -16,6 +16,7 @@ const getDefine = env => ({ 'process.env.OIDC_ENABLED': yn(env.OIDC_ENABLED), 'process.env.OIDC_LOGOUT_ENABLED': yn(env.OIDC_LOGOUT_ENABLED), 'process.env.OIDC_LOGIN_TEXT': JSON.stringify(env.OIDC_LOGIN_TEXT), + 'process.env.EXTERNAL_ACCESS_MANAGEMENT': yn(env.EXTERNAL_ACCESS_MANAGEMENT), 'BRAND_CONFIG.TITLE': JSON.stringify(brandConfig.title), 'BRAND_CONFIG.FAVICON': JSON.stringify(brandConfig.favicon), 'BRAND_CONFIG.LOGO_COMPACT': JSON.stringify(brandConfig.logo.compact),