diff --git a/app/components/Sidebar/Settings.js b/app/components/Sidebar/Settings.js index d0fde95f..03a48eba 100644 --- a/app/components/Sidebar/Settings.js +++ b/app/components/Sidebar/Settings.js @@ -10,6 +10,7 @@ import { UserIcon, LinkIcon, TeamIcon, + BulletedListIcon, } from 'outline-icons'; import ZapierIcon from './icons/Zapier'; import SlackIcon from './icons/Slack'; @@ -94,6 +95,13 @@ class SettingsSidebar extends React.Component { icon={} label="Share Links" /> + {user.isAdmin && ( + } + label="Audit Log" + /> + )} {user.isAdmin && ( + { + @observable isLoaded: boolean = false; + @observable isFetching: boolean = false; + @observable offset: number = 0; + @observable allowLoadMore: boolean = true; + + componentDidMount() { + this.fetchResults(); + } + + fetchResults = async () => { + this.isFetching = true; + + const limit = DEFAULT_PAGINATION_LIMIT; + const results = await this.props.events.fetchPage({ + limit, + offset: this.offset, + auditLog: true, + }); + + if ( + results && + (results.length === 0 || results.length < DEFAULT_PAGINATION_LIMIT) + ) { + this.allowLoadMore = false; + } else { + this.offset += DEFAULT_PAGINATION_LIMIT; + } + + this.isLoaded = true; + this.isFetching = false; + }; + + @action + loadMoreResults = async () => { + // Don't paginate if there aren't more results or we’re in the middle of fetching + if (!this.allowLoadMore || this.isFetching) return; + await this.fetchResults(); + }; + + render() { + const { events } = this.props; + const showLoading = events.isFetching && !events.orderedData.length; + + return ( + + +

Audit Log

+ + The audit log details the history of security related and other events + across your knowledgebase. + + + + + Events + + + + {showLoading ? ( + + ) : ( + + {events.orderedData.map(event => )} + {this.allowLoadMore && ( + + )} + + )} + +
+ ); + } +} + +export default inject('events')(Events); diff --git a/app/scenes/Settings/components/EventListItem.js b/app/scenes/Settings/components/EventListItem.js new file mode 100644 index 00000000..ef97ca7f --- /dev/null +++ b/app/scenes/Settings/components/EventListItem.js @@ -0,0 +1,125 @@ +// @flow +import * as React from 'react'; +import { Link } from 'react-router-dom'; +import { capitalize } from 'lodash'; +import styled from 'styled-components'; +import Time from 'shared/components/Time'; +import ListItem from 'components/List/Item'; +import Avatar from 'components/Avatar'; +import Event from 'models/Event'; + +type Props = { + event: Event, +}; + +const description = event => { + switch (event.name) { + case 'teams.create': + return 'Created the team'; + case 'shares.create': + case 'shares.revoke': + return ( + + {capitalize(event.verbPastTense)} a{' '} + public link to a{' '} + document + + ); + case 'users.create': + return ( + {event.data.name} created an account + ); + case 'users.invite': + return ( + + {capitalize(event.verbPastTense)} {event.data.name} ( + {event.data.email || ''} + ) + + ); + case 'collections.add_user': + return ( + + Added {event.data.name} to a private{' '} + + collection + + + ); + case 'collections.remove_user': + return ( + + Remove {event.data.name} from a private{' '} + + collection + + + ); + default: + } + + if (event.documentId) { + return ( + + {capitalize(event.verbPastTense)} a{' '} + document + + ); + } + if (event.collectionId) { + return ( + + {capitalize(event.verbPastTense)} a{' '} + collection + + ); + } + if (event.userId) { + return ( + + {capitalize(event.verbPastTense)} the user {event.data.name} + + ); + } + return ''; +}; + +const EventListItem = ({ event }: Props) => { + return ( + } + subtitle={ + + {description(event)} + } + actions={ + event.actorIpAddress ? ( + + + {event.actorIpAddress} + + + ) : ( + undefined + ) + } + /> + ); +}; + +const IP = styled('span')` + color: ${props => props.theme.textTertiary}; + font-size: 12px; +`; + +export default EventListItem; diff --git a/app/stores/BaseStore.js b/app/stores/BaseStore.js index 51d40891..3710f081 100644 --- a/app/stores/BaseStore.js +++ b/app/stores/BaseStore.js @@ -153,6 +153,7 @@ export default class BaseStore { res.data.forEach(this.add); this.isLoaded = true; }); + return res.data; } finally { this.isFetching = false; } diff --git a/app/stores/EventsStore.js b/app/stores/EventsStore.js new file mode 100644 index 00000000..0b967b23 --- /dev/null +++ b/app/stores/EventsStore.js @@ -0,0 +1,19 @@ +// @flow +import { sortBy } from 'lodash'; +import { computed } from 'mobx'; +import BaseStore from './BaseStore'; +import RootStore from './RootStore'; +import Event from 'models/Event'; + +export default class EventsStore extends BaseStore { + actions = ['list']; + + constructor(rootStore: RootStore) { + super(rootStore, Event); + } + + @computed + get orderedData(): Event[] { + return sortBy(Array.from(this.data.values()), 'createdAt').reverse(); + } +} diff --git a/app/stores/RootStore.js b/app/stores/RootStore.js index 0026d6a1..df49a2b4 100644 --- a/app/stores/RootStore.js +++ b/app/stores/RootStore.js @@ -3,6 +3,7 @@ import ApiKeysStore from './ApiKeysStore'; import AuthStore from './AuthStore'; import CollectionsStore from './CollectionsStore'; import DocumentsStore from './DocumentsStore'; +import EventsStore from './EventsStore'; import IntegrationsStore from './IntegrationsStore'; import NotificationSettingsStore from './NotificationSettingsStore'; import RevisionsStore from './RevisionsStore'; @@ -16,6 +17,7 @@ export default class RootStore { auth: AuthStore; collections: CollectionsStore; documents: DocumentsStore; + events: EventsStore; integrations: IntegrationsStore; notificationSettings: NotificationSettingsStore; revisions: RevisionsStore; @@ -29,6 +31,7 @@ export default class RootStore { this.auth = new AuthStore(this); this.collections = new CollectionsStore(this); this.documents = new DocumentsStore(this); + this.events = new EventsStore(this); this.integrations = new IntegrationsStore(this); this.notificationSettings = new NotificationSettingsStore(this); this.revisions = new RevisionsStore(this); @@ -42,6 +45,7 @@ export default class RootStore { this.apiKeys.clear(); this.collections.clear(); this.documents.clear(); + this.events.clear(); this.integrations.clear(); this.notificationSettings.clear(); this.revisions.clear(); diff --git a/server/api/__snapshots__/events.test.js.snap b/server/api/__snapshots__/events.test.js.snap new file mode 100644 index 00000000..e3297cc6 --- /dev/null +++ b/server/api/__snapshots__/events.test.js.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#events.list should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; diff --git a/server/api/collections.js b/server/api/collections.js index e03a9ac3..ef411a3d 100644 --- a/server/api/collections.js +++ b/server/api/collections.js @@ -4,12 +4,11 @@ import Router from 'koa-router'; import auth from '../middlewares/authentication'; import pagination from './middlewares/pagination'; import { presentCollection, presentUser } from '../presenters'; -import { Collection, CollectionUser, Team, User } from '../models'; +import { Collection, CollectionUser, Team, Event, User } from '../models'; import { ValidationError, InvalidRequestError } from '../errors'; import { exportCollections } from '../logistics'; import { archiveCollection } from '../utils/zip'; import policy from '../policies'; -import events from '../events'; const { authorize } = policy; const router = new Router(); @@ -35,11 +34,13 @@ router.post('collections.create', auth(), async ctx => { private: isPrivate, }); - events.add({ + await Event.create({ name: 'collections.create', - modelId: collection.id, + collectionId: collection.id, teamId: collection.teamId, actorId: user.id, + data: { name }, + ip: ctx.request.ip, }); ctx.body = { @@ -81,12 +82,14 @@ router.post('collections.add_user', auth(), async ctx => { createdById: ctx.state.user.id, }); - events.add({ + await Event.create({ name: 'collections.add_user', - modelId: userId, + userId, collectionId: collection.id, teamId: collection.teamId, actorId: ctx.state.user.id, + data: { name: user.name }, + ip: ctx.request.ip, }); ctx.body = { @@ -111,12 +114,14 @@ router.post('collections.remove_user', auth(), async ctx => { await collection.removeUser(user); - events.add({ + await Event.create({ name: 'collections.remove_user', - modelId: userId, + userId, collectionId: collection.id, teamId: collection.teamId, actorId: ctx.state.user.id, + data: { name: user.name }, + ip: ctx.request.ip, }); ctx.body = { @@ -148,6 +153,15 @@ router.post('collections.export', auth(), async ctx => { const filePath = await archiveCollection(collection); + await Event.create({ + name: 'collections.export', + collectionId: collection.id, + teamId: user.teamId, + actorId: user.id, + data: { title: collection.title }, + ip: ctx.request.ip, + }); + ctx.attachment(`${collection.name}.zip`); ctx.set('Content-Type', 'application/force-download'); ctx.body = fs.createReadStream(filePath); @@ -161,6 +175,13 @@ router.post('collections.exportAll', auth(), async ctx => { // async operation to create zip archive and email user exportCollections(user.teamId, user.email); + await Event.create({ + name: 'collections.export', + teamId: user.teamId, + actorId: user.id, + ip: ctx.request.ip, + }); + ctx.body = { success: true, }; @@ -197,11 +218,13 @@ router.post('collections.update', auth(), async ctx => { collection.private = isPrivate; await collection.save(); - events.add({ + await Event.create({ name: 'collections.update', - modelId: collection.id, + collectionId: collection.id, teamId: collection.teamId, actorId: user.id, + data: { name }, + ip: ctx.request.ip, }); ctx.body = { @@ -246,11 +269,13 @@ router.post('collections.delete', auth(), async ctx => { await collection.destroy(); - events.add({ + await Event.create({ name: 'collections.delete', - modelId: collection.id, + collectionId: collection.id, teamId: collection.teamId, actorId: user.id, + data: { name: collection.name }, + ip: ctx.request.ip, }); ctx.body = { diff --git a/server/api/documents.js b/server/api/documents.js index 10c99ec5..ac16c2e7 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -10,8 +10,9 @@ import { presentRevision, } from '../presenters'; import { - Document, Collection, + Document, + Event, Share, Star, View, @@ -20,7 +21,6 @@ import { User, } from '../models'; import { InvalidRequestError } from '../errors'; -import events from '../events'; import policy from '../policies'; import { sequelize } from '../sequelize'; @@ -369,12 +369,14 @@ router.post('documents.restore', auth(), async ctx => { // restore a previously archived document await document.unarchive(user.id); - events.add({ + await Event.create({ name: 'documents.unarchive', - modelId: document.id, + documentId: document.id, collectionId: document.collectionId, teamId: document.teamId, actorId: user.id, + data: { title: document.title }, + ip: ctx.request.ip, }); } else if (revisionId) { // restore a document to a specific revision @@ -387,12 +389,14 @@ router.post('documents.restore', auth(), async ctx => { document.title = revision.title; await document.save(); - events.add({ + await Event.create({ name: 'documents.restore', - modelId: document.id, + documentId: document.id, collectionId: document.collectionId, teamId: document.teamId, actorId: user.id, + data: { title: document.title }, + ip: ctx.request.ip, }); } else { ctx.assertPresent(revisionId, 'revisionId is required'); @@ -463,12 +467,14 @@ router.post('documents.pin', auth(), async ctx => { document.pinnedById = user.id; await document.save(); - events.add({ + await Event.create({ name: 'documents.pin', - modelId: document.id, + documentId: document.id, collectionId: document.collectionId, teamId: document.teamId, actorId: user.id, + data: { title: document.title }, + ip: ctx.request.ip, }); ctx.body = { @@ -487,12 +493,14 @@ router.post('documents.unpin', auth(), async ctx => { document.pinnedById = null; await document.save(); - events.add({ + await Event.create({ name: 'documents.unpin', - modelId: document.id, + documentId: document.id, collectionId: document.collectionId, teamId: document.teamId, actorId: user.id, + data: { title: document.title }, + ip: ctx.request.ip, }); ctx.body = { @@ -512,12 +520,14 @@ router.post('documents.star', auth(), async ctx => { where: { documentId: document.id, userId: user.id }, }); - events.add({ + await Event.create({ name: 'documents.star', - modelId: document.id, + documentId: document.id, collectionId: document.collectionId, teamId: document.teamId, actorId: user.id, + data: { title: document.title }, + ip: ctx.request.ip, }); }); @@ -533,12 +543,14 @@ router.post('documents.unstar', auth(), async ctx => { where: { documentId: document.id, userId: user.id }, }); - events.add({ + await Event.create({ name: 'documents.unstar', modelId: document.id, collectionId: document.collectionId, teamId: document.teamId, actorId: user.id, + data: { title: document.title }, + ip: ctx.request.ip, }); }); @@ -592,23 +604,27 @@ router.post('documents.create', auth(), async ctx => { text, }); - events.add({ + await Event.create({ name: 'documents.create', - modelId: document.id, + documentId: document.id, collectionId: document.collectionId, teamId: document.teamId, actorId: user.id, + data: { title: document.title }, + ip: ctx.request.ip, }); if (publish) { await document.publish(); - events.add({ + await Event.create({ name: 'documents.publish', - modelId: document.id, + documentId: document.id, collectionId: document.collectionId, teamId: document.teamId, actorId: user.id, + data: { title: document.title }, + ip: ctx.request.ip, }); } @@ -664,29 +680,10 @@ router.post('documents.update', auth(), async ctx => { if (publish) { await document.publish({ transaction }); - await transaction.commit(); - - events.add({ - name: 'documents.publish', - modelId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: user.id, - }); } else { await document.save({ autosave, transaction }); - await transaction.commit(); - - events.add({ - name: 'documents.update', - modelId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: user.id, - autosave, - done, - }); } + await transaction.commit(); } catch (err) { if (transaction) { await transaction.rollback(); @@ -694,6 +691,32 @@ router.post('documents.update', auth(), async ctx => { throw err; } + if (publish) { + await Event.create({ + name: 'documents.publish', + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: user.id, + data: { title: document.title }, + ip: ctx.request.ip, + }); + } else { + await Event.create({ + name: 'documents.update', + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: user.id, + data: { + autosave, + done, + title: document.title, + }, + ip: ctx.request.ip, + }); + } + ctx.body = { data: await presentDocument(document), }; @@ -735,10 +758,12 @@ router.post('documents.move', auth(), async ctx => { } const { documents, collections } = await documentMover({ + user, document, collectionId, parentDocumentId, index, + ip: ctx.request.ip, }); ctx.body = { @@ -763,12 +788,14 @@ router.post('documents.archive', auth(), async ctx => { await document.archive(user.id); - events.add({ + await Event.create({ name: 'documents.archive', - modelId: document.id, + documentId: document.id, collectionId: document.collectionId, teamId: document.teamId, actorId: user.id, + data: { title: document.title }, + ip: ctx.request.ip, }); ctx.body = { @@ -786,12 +813,14 @@ router.post('documents.delete', auth(), async ctx => { await document.delete(); - events.add({ + await Event.create({ name: 'documents.delete', - modelId: document.id, + documentId: document.id, collectionId: document.collectionId, teamId: document.teamId, actorId: user.id, + data: { title: document.title }, + ip: ctx.request.ip, }); ctx.body = { diff --git a/server/api/events.js b/server/api/events.js new file mode 100644 index 00000000..aa1c5e77 --- /dev/null +++ b/server/api/events.js @@ -0,0 +1,58 @@ +// @flow +import Sequelize from 'sequelize'; +import Router from 'koa-router'; +import auth from '../middlewares/authentication'; +import pagination from './middlewares/pagination'; +import { presentEvent } from '../presenters'; +import { Event, Team, User } from '../models'; +import policy from '../policies'; + +const Op = Sequelize.Op; +const { authorize } = policy; +const router = new Router(); + +router.post('events.list', auth(), pagination(), async ctx => { + let { sort = 'updatedAt', direction, auditLog = false } = ctx.body; + if (direction !== 'ASC') direction = 'DESC'; + + const user = ctx.state.user; + const collectionIds = await user.collectionIds(); + + let where = { + name: Event.ACTIVITY_EVENTS, + teamId: user.teamId, + [Op.or]: [ + { collectionId: collectionIds }, + { + collectionId: { + [Op.eq]: null, + }, + }, + ], + }; + + if (auditLog) { + authorize(user, 'auditLog', Team); + where.name = Event.AUDIT_EVENTS; + } + + const events = await Event.findAll({ + where, + order: [[sort, direction]], + include: [ + { + model: User, + as: 'actor', + }, + ], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + + ctx.body = { + pagination: ctx.state.pagination, + data: events.map(event => presentEvent(event, auditLog)), + }; +}); + +export default router; diff --git a/server/api/events.test.js b/server/api/events.test.js new file mode 100644 index 00000000..72442685 --- /dev/null +++ b/server/api/events.test.js @@ -0,0 +1,49 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ +import TestServer from 'fetch-test-server'; +import app from '../app'; +import { flushdb, seed } from '../test/support'; +import { buildEvent } from '../test/factories'; + +const server = new TestServer(app.callback()); + +beforeEach(flushdb); +afterAll(server.close); + +describe('#events.list', async () => { + it('should only return activity events', async () => { + const { user, admin, document, collection } = await seed(); + + // private event + await buildEvent({ + name: 'users.promote', + teamId: user.teamId, + actorId: admin.id, + userId: user.id, + }); + + // event viewable in activity stream + const event = await buildEvent({ + name: 'documents.publish', + collectionId: collection.id, + documentId: document.id, + teamId: user.teamId, + actorId: admin.id, + }); + const res = await server.post('/api/events.list', { + body: { token: user.getJwtToken() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + expect(body.data[0].id).toEqual(event.id); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/events.list'); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); +}); diff --git a/server/api/index.js b/server/api/index.js index be6d4e45..a396d35c 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -4,6 +4,7 @@ import Koa from 'koa'; import Router from 'koa-router'; import auth from './auth'; +import events from './events'; import users from './users'; import collections from './collections'; import documents from './documents'; @@ -35,6 +36,7 @@ api.use(apiWrapper()); // routes router.use('/', auth.routes()); +router.use('/', events.routes()); router.use('/', users.routes()); router.use('/', collections.routes()); router.use('/', documents.routes()); diff --git a/server/api/integrations.js b/server/api/integrations.js index 6eb6eb93..5a7fbcae 100644 --- a/server/api/integrations.js +++ b/server/api/integrations.js @@ -3,9 +3,9 @@ import Router from 'koa-router'; import Integration from '../models/Integration'; import pagination from './middlewares/pagination'; import auth from '../middlewares/authentication'; +import { Event } from '../models'; import { presentIntegration } from '../presenters'; import policy from '../policies'; -import events from '../events'; const { authorize } = policy; const router = new Router(); @@ -38,11 +38,12 @@ router.post('integrations.delete', auth(), async ctx => { await integration.destroy(); - events.add({ + await Event.create({ name: 'integrations.delete', modelId: integration.id, teamId: integration.teamId, actorId: user.id, + ip: ctx.request.ip, }); ctx.body = { diff --git a/server/api/shares.js b/server/api/shares.js index d66ec48c..1749635d 100644 --- a/server/api/shares.js +++ b/server/api/shares.js @@ -4,7 +4,7 @@ import Sequelize from 'sequelize'; import auth from '../middlewares/authentication'; import pagination from './middlewares/pagination'; import { presentShare } from '../presenters'; -import { Document, User, Share, Team } from '../models'; +import { Document, User, Event, Share, Team } from '../models'; import policy from '../policies'; const Op = Sequelize.Op; @@ -50,6 +50,7 @@ router.post('shares.list', auth(), pagination(), async ctx => { }); ctx.body = { + pagination: ctx.state.pagination, data: shares.map(presentShare), }; }); @@ -73,6 +74,17 @@ router.post('shares.create', auth(), async ctx => { }, }); + await Event.create({ + name: 'shares.create', + documentId, + collectionId: document.collectionId, + modelId: share.id, + teamId: user.teamId, + actorId: user.id, + data: { name: document.title }, + ip: ctx.request.ip, + }); + share.user = user; share.document = document; @@ -91,6 +103,19 @@ router.post('shares.revoke', auth(), async ctx => { await share.revoke(user.id); + const document = await Document.findByPk(share.documentId); + + await Event.create({ + name: 'shares.revoke', + documentId: document.id, + collectionId: document.collectionId, + modelId: share.id, + teamId: user.teamId, + actorId: user.id, + data: { name: document.title }, + ip: ctx.request.ip, + }); + ctx.body = { success: true, }; diff --git a/server/api/users.js b/server/api/users.js index c68da860..5254e36f 100644 --- a/server/api/users.js +++ b/server/api/users.js @@ -86,6 +86,7 @@ router.post('users.s3Upload', auth(), async ctx => { }, teamId: ctx.state.user.teamId, userId: ctx.state.user.id, + ip: ctx.request.ip, }); ctx.body = { @@ -126,6 +127,15 @@ router.post('users.promote', auth(), async ctx => { const team = await Team.findByPk(teamId); await team.addAdmin(user); + await Event.create({ + name: 'users.promote', + actorId: ctx.state.user.id, + userId, + teamId, + data: { name: user.name }, + ip: ctx.request.ip, + }); + ctx.body = { data: presentUser(user, { includeDetails: true }), }; @@ -146,6 +156,15 @@ router.post('users.demote', auth(), async ctx => { throw new ValidationError(err.message); } + await Event.create({ + name: 'users.demote', + actorId: ctx.state.user.id, + userId, + teamId, + data: { name: user.name }, + ip: ctx.request.ip, + }); + ctx.body = { data: presentUser(user, { includeDetails: true }), }; @@ -167,6 +186,15 @@ router.post('users.suspend', auth(), async ctx => { throw new ValidationError(err.message); } + await Event.create({ + name: 'users.suspend', + actorId: ctx.state.user.id, + userId, + teamId, + data: { name: user.name }, + ip: ctx.request.ip, + }); + ctx.body = { data: presentUser(user, { includeDetails: true }), }; @@ -184,6 +212,15 @@ router.post('users.activate', auth(), async ctx => { const team = await Team.findByPk(teamId); await team.activateUser(user, admin); + await Event.create({ + name: 'users.activate', + actorId: ctx.state.user.id, + userId, + teamId, + data: { name: user.name }, + ip: ctx.request.ip, + }); + ctx.body = { data: presentUser(user, { includeDetails: true }), }; @@ -196,7 +233,7 @@ router.post('users.invite', auth(), async ctx => { const user = ctx.state.user; authorize(user, 'invite', User); - const invitesSent = await userInviter({ user, invites }); + const invitesSent = await userInviter({ user, invites, ip: ctx.request.ip }); ctx.body = { data: invitesSent, @@ -216,6 +253,15 @@ router.post('users.delete', auth(), async ctx => { throw new ValidationError(err.message); } + await Event.create({ + name: 'users.delete', + actorId: user.id, + userId: user.id, + teamId: user.teamId, + data: { name: user.name }, + ip: ctx.request.ip, + }); + ctx.body = { success: true, }; diff --git a/server/api/views.js b/server/api/views.js index b148a141..31c8bd73 100644 --- a/server/api/views.js +++ b/server/api/views.js @@ -2,7 +2,7 @@ import Router from 'koa-router'; import auth from '../middlewares/authentication'; import { presentView } from '../presenters'; -import { View, Document, User } from '../models'; +import { View, Document, Event, User } from '../models'; import policy from '../policies'; const { authorize } = policy; @@ -42,6 +42,16 @@ router.post('views.create', auth(), async ctx => { await View.increment({ documentId, userId: user.id }); + await Event.create({ + name: 'views.create', + actorId: user.id, + documentId: document.id, + collectionId: document.collectionId, + teamId: user.teamId, + data: { title: document.title }, + ip: ctx.request.ip, + }); + ctx.body = { success: true, }; diff --git a/server/auth/google.js b/server/auth/google.js index 9c18cea0..0b0a31ff 100644 --- a/server/auth/google.js +++ b/server/auth/google.js @@ -3,7 +3,7 @@ import crypto from 'crypto'; import Router from 'koa-router'; import { capitalize } from 'lodash'; import { OAuth2Client } from 'google-auth-library'; -import { User, Team } from '../models'; +import { User, Team, Event } from '../models'; import auth from '../middlewares/authentication'; const router = new Router(); @@ -91,6 +91,20 @@ router.get('google.callback', auth({ required: false }), async ctx => { }, }); + if (isFirstSignin) { + await Event.create({ + name: 'users.create', + actorId: user.id, + userId: user.id, + teamId: team.id, + data: { + name: user.name, + service: 'google', + }, + ip: ctx.request.ip, + }); + } + // update email address if it's changed in Google if (!isFirstSignin && profile.data.email !== user.email) { await user.update({ email: profile.data.email }); diff --git a/server/auth/slack.js b/server/auth/slack.js index 7e1171df..b6446cab 100644 --- a/server/auth/slack.js +++ b/server/auth/slack.js @@ -4,7 +4,14 @@ import auth from '../middlewares/authentication'; import addHours from 'date-fns/add_hours'; import { stripSubdomain } from '../../shared/utils/domains'; import { slackAuth } from '../../shared/utils/routeHelpers'; -import { Authentication, Collection, Integration, User, Team } from '../models'; +import { + Authentication, + Collection, + Integration, + User, + Event, + Team, +} from '../models'; import * as Slack from '../slack'; const router = new Router(); @@ -69,6 +76,20 @@ router.get('slack.callback', auth({ required: false }), async ctx => { await team.provisionSubdomain(data.team.domain); } + if (isFirstSignin) { + await Event.create({ + name: 'users.create', + actorId: user.id, + userId: user.id, + teamId: team.id, + data: { + name: user.name, + service: 'slack', + }, + ip: ctx.request.ip, + }); + } + // update email address if it's changed in Slack if (!isFirstSignin && data.user.email !== user.email) { await user.update({ email: data.user.email }); diff --git a/server/commands/documentMover.js b/server/commands/documentMover.js index 834b1348..d5893d8d 100644 --- a/server/commands/documentMover.js +++ b/server/commands/documentMover.js @@ -1,18 +1,22 @@ // @flow -import { Document, Collection } from '../models'; +import { Document, Collection, Event } from '../models'; import { sequelize } from '../sequelize'; -import events from '../events'; +import { type Context } from 'koa'; export default async function documentMover({ + user, document, collectionId, parentDocumentId, index, + ip, }: { + user: Context, document: Document, collectionId: string, parentDocumentId: string, index?: number, + ip: string, }) { let transaction; const result = { collections: [], documents: [] }; @@ -72,12 +76,18 @@ export default async function documentMover({ await transaction.commit(); - events.add({ + await Event.create({ name: 'documents.move', - modelId: document.id, - collectionIds: result.collections.map(c => c.id), - documentIds: result.documents.map(d => d.id), + actorId: user.id, + documentId: document.id, + collectionId, teamId: document.teamId, + data: { + title: document.title, + collectionIds: result.collections.map(c => c.id), + documentIds: result.documents.map(d => d.id), + }, + ip, }); } catch (err) { if (transaction) { diff --git a/server/commands/documentMover.test.js b/server/commands/documentMover.test.js index 0134c6e0..904ed7d4 100644 --- a/server/commands/documentMover.test.js +++ b/server/commands/documentMover.test.js @@ -6,12 +6,16 @@ import { buildDocument, buildCollection } from '../test/factories'; beforeEach(flushdb); describe('documentMover', async () => { + const ip = '127.0.0.1'; + it('should move within a collection', async () => { - const { document, collection } = await seed(); + const { document, user, collection } = await seed(); const response = await documentMover({ + user, document, collectionId: collection.id, + ip, }); expect(response.collections.length).toEqual(1); @@ -19,7 +23,7 @@ describe('documentMover', async () => { }); it('should move with children', async () => { - const { document, collection } = await seed(); + const { document, user, collection } = await seed(); const newDocument = await buildDocument({ parentDocumentId: document.id, collectionId: collection.id, @@ -31,10 +35,12 @@ describe('documentMover', async () => { await collection.addDocumentToStructure(newDocument); const response = await documentMover({ + user, document, collectionId: collection.id, parentDocumentId: undefined, index: 0, + ip, }); expect(response.collections[0].documentStructure[0].children[0].id).toBe( @@ -45,7 +51,7 @@ describe('documentMover', async () => { }); it('should move with children to another collection', async () => { - const { document, collection } = await seed(); + const { document, user, collection } = await seed(); const newCollection = await buildCollection({ teamId: collection.teamId, }); @@ -60,10 +66,12 @@ describe('documentMover', async () => { await collection.addDocumentToStructure(newDocument); const response = await documentMover({ + user, document, collectionId: newCollection.id, parentDocumentId: undefined, index: 0, + ip, }); // check document ids where updated diff --git a/server/commands/userInviter.js b/server/commands/userInviter.js index e19212d9..7ff1a3d5 100644 --- a/server/commands/userInviter.js +++ b/server/commands/userInviter.js @@ -1,7 +1,6 @@ // @flow import { uniqBy } from 'lodash'; -import { User, Team } from '../models'; -import events from '../events'; +import { User, Event, Team } from '../models'; import mailer from '../mailer'; type Invite = { name: string, email: string }; @@ -9,9 +8,11 @@ type Invite = { name: string, email: string }; export default async function userInviter({ user, invites, + ip, }: { user: User, invites: Invite[], + ip: string, }): Promise<{ sent: Invite[] }> { const team = await Team.findByPk(user.teamId); @@ -35,23 +36,28 @@ export default async function userInviter({ ); // send and record invites - filteredInvites.forEach(async invite => { - await mailer.invite({ - to: invite.email, - name: invite.name, - actorName: user.name, - actorEmail: user.email, - teamName: team.name, - teamUrl: team.url, - }); - - events.add({ - name: 'users.invite', - actorId: user.id, - teamId: user.teamId, - email: invite.email, - }); - }); + await Promise.all( + filteredInvites.map(async invite => { + await Event.create({ + name: 'users.invite', + actorId: user.id, + teamId: user.teamId, + data: { + email: invite.email, + name: invite.name, + }, + ip, + }); + await mailer.invite({ + to: invite.email, + name: invite.name, + actorName: user.name, + actorEmail: user.email, + teamName: team.name, + teamUrl: team.url, + }); + }) + ); return { sent: filteredInvites }; } diff --git a/server/commands/userInviter.test.js b/server/commands/userInviter.test.js index 10c45767..53c85edc 100644 --- a/server/commands/userInviter.test.js +++ b/server/commands/userInviter.test.js @@ -6,11 +6,14 @@ import { buildUser } from '../test/factories'; beforeEach(flushdb); describe('userInviter', async () => { + const ip = '127.0.0.1'; + it('should return sent invites', async () => { const user = await buildUser(); const response = await userInviter({ invites: [{ email: 'test@example.com', name: 'Test' }], user, + ip, }); expect(response.sent.length).toEqual(1); }); @@ -20,6 +23,7 @@ describe('userInviter', async () => { const response = await userInviter({ invites: [{ email: ' ', name: 'Test' }], user, + ip, }); expect(response.sent.length).toEqual(0); }); @@ -29,6 +33,7 @@ describe('userInviter', async () => { const response = await userInviter({ invites: [{ email: 'notanemail', name: 'Test' }], user, + ip, }); expect(response.sent.length).toEqual(0); }); @@ -41,6 +46,7 @@ describe('userInviter', async () => { { email: 'the@same.com', name: 'Test' }, ], user, + ip, }); expect(response.sent.length).toEqual(1); }); @@ -50,6 +56,7 @@ describe('userInviter', async () => { const response = await userInviter({ invites: [{ email: user.email, name: user.name }], user, + ip, }); expect(response.sent.length).toEqual(0); }); diff --git a/server/events.js b/server/events.js index fca95f03..44c3f1eb 100644 --- a/server/events.js +++ b/server/events.js @@ -9,7 +9,7 @@ export type UserEvent = | 'users.suspend' | 'users.activate' | 'users.delete', - modelId: string, + userId: string, teamId: string, actorId: string, } @@ -17,7 +17,10 @@ export type UserEvent = name: 'users.invite', teamId: string, actorId: string, - email: string, + data: { + email: string, + name: string, + }, }; export type DocumentEvent = @@ -32,27 +35,32 @@ export type DocumentEvent = | 'documents.restore' | 'documents.star' | 'documents.unstar', - modelId: string, + documentId: string, collectionId: string, teamId: string, actorId: string, } | { name: 'documents.move', - modelId: string, - collectionIds: string[], - documentIds: string[], - teamId: string, - actorId: string, - } - | { - name: 'documents.update', - modelId: string, + documentId: string, collectionId: string, teamId: string, actorId: string, - autosave: boolean, - done: boolean, + data: { + collectionIds: string[], + documentIds: string[], + }, + } + | { + name: 'documents.update', + documentId: string, + collectionId: string, + teamId: string, + actorId: string, + data: { + autosave: boolean, + done: boolean, + }, }; export type CollectionEvent = @@ -60,20 +68,20 @@ export type CollectionEvent = name: | 'collections.create' // eslint-disable-line | 'collections.update' | 'collections.delete', - modelId: string, + collectionId: string, teamId: string, actorId: string, } | { name: 'collections.add_user' | 'collections.remove_user', - modelId: string, + userId: string, collectionId: string, teamId: string, actorId: string, }; export type IntegrationEvent = { - name: 'integrations.create' | 'integrations.update' | 'collections.delete', + name: 'integrations.create' | 'integrations.update', modelId: string, teamId: string, actorId: string, diff --git a/server/migrations/20190606035733-events.js b/server/migrations/20190606035733-events.js new file mode 100644 index 00000000..50c0a2e0 --- /dev/null +++ b/server/migrations/20190606035733-events.js @@ -0,0 +1,35 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.changeColumn('events', 'data', { + type: Sequelize.JSONB, + allowNull: true, + }); + await queryInterface.addColumn('events', 'actorId', { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'users', + }, + }); + await queryInterface.addColumn('events', 'modelId', { + type: Sequelize.UUID, + allowNull: true + }); + await queryInterface.addColumn('events', 'ip', { + type: Sequelize.STRING, + allowNull: true + }); + await queryInterface.addIndex('events', ['name']); + await queryInterface.addIndex('events', ['actorId']); + await queryInterface.addIndex('events', ['teamId', 'collectionId']); + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.removeColumn('events', 'actorId'); + await queryInterface.removeColumn('events', 'modelId'); + await queryInterface.removeColumn('events', 'ip'); + + await queryInterface.removeIndex('events', ['name']); + await queryInterface.removeIndex('events', ['actorId']); + await queryInterface.removeIndex('events', ['teamId', 'collectionId']); + } +} \ No newline at end of file diff --git a/server/models/Event.js b/server/models/Event.js index a36cb01d..6f6b2ee9 100644 --- a/server/models/Event.js +++ b/server/models/Event.js @@ -1,5 +1,6 @@ // @flow import { DataTypes, sequelize } from '../sequelize'; +import events from '../events'; const Event = sequelize.define('event', { id: { @@ -8,6 +9,7 @@ const Event = sequelize.define('event', { primaryKey: true, }, name: DataTypes.STRING, + ip: DataTypes.STRING, data: DataTypes.JSONB, }); @@ -16,6 +18,10 @@ Event.associate = models => { as: 'user', foreignKey: 'userId', }); + Event.belongsTo(models.User, { + as: 'actor', + foreignKey: 'actorId', + }); Event.belongsTo(models.Collection, { as: 'collection', foreignKey: 'collectionId', @@ -30,4 +36,52 @@ Event.associate = models => { }); }; +Event.beforeCreate(event => { + if (event.ip) { + // cleanup IPV6 representations of IPV4 addresses + event.ip = event.ip.replace(/^::ffff:/, ''); + } +}); + +Event.afterCreate(event => { + events.add(event); +}); + +Event.ACTIVITY_EVENTS = [ + 'users.create', + 'documents.publish', + 'documents.archive', + 'documents.unarchive', + 'documents.pin', + 'documents.unpin', + 'documents.delete', + 'collections.create', + 'collections.delete', +]; + +Event.AUDIT_EVENTS = [ + 'users.create', + 'users.promote', + 'users.demote', + 'users.invite', + 'users.suspend', + 'users.activate', + 'users.delete', + 'documents.publish', + 'documents.update', + 'documents.archive', + 'documents.unarchive', + 'documents.pin', + 'documents.unpin', + 'documents.move', + 'documents.delete', + 'shares.create', + 'shares.revoke', + 'collections.create', + 'collections.update', + 'collections.add_user', + 'collections.remove_user', + 'collections.delete', +]; + export default Event; diff --git a/server/models/User.js b/server/models/User.js index e813903d..159a4a49 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -172,22 +172,24 @@ User.afterCreate(async user => { // By default when a user signs up we subscribe them to email notifications // when documents they created are edited by other team members and onboarding User.afterCreate(async (user, options) => { - await NotificationSetting.findOrCreate({ - where: { - userId: user.id, - teamId: user.teamId, - event: 'documents.update', - }, - transaction: options.transaction, - }); - await NotificationSetting.findOrCreate({ - where: { - userId: user.id, - teamId: user.teamId, - event: 'emails.onboarding', - }, - transaction: options.transaction, - }); + await Promise.all([ + NotificationSetting.findOrCreate({ + where: { + userId: user.id, + teamId: user.teamId, + event: 'documents.update', + }, + transaction: options.transaction, + }), + NotificationSetting.findOrCreate({ + where: { + userId: user.id, + teamId: user.teamId, + event: 'emails.onboarding', + }, + transaction: options.transaction, + }), + ]); }); export default User; diff --git a/server/pages/developers/Api.js b/server/pages/developers/Api.js index 3aceabc8..056e072e 100644 --- a/server/pages/developers/Api.js +++ b/server/pages/developers/Api.js @@ -24,6 +24,16 @@ export default function Api() { + + List all of the events in the team. + + + + + List all of the users in the team. diff --git a/server/policies/team.js b/server/policies/team.js index 414a9cc3..bba7f0ae 100644 --- a/server/policies/team.js +++ b/server/policies/team.js @@ -12,6 +12,11 @@ allow(User, 'share', Team, (user, team) => { return team.sharing; }); +allow(User, 'auditLog', Team, user => { + if (user.isAdmin) return true; + return false; +}); + allow(User, ['update', 'export'], Team, (user, team) => { if (!team || user.teamId !== team.id) return false; if (user.isAdmin) return true; diff --git a/server/presenters/event.js b/server/presenters/event.js new file mode 100644 index 00000000..67e59640 --- /dev/null +++ b/server/presenters/event.js @@ -0,0 +1,24 @@ +// @flow +import { Event } from '../models'; +import presentUser from './user'; + +export default function present(event: Event, auditLog: boolean = false) { + let data = { + id: event.id, + name: event.name, + modelId: event.modelId, + actorId: event.actorId, + actorIpAddress: event.ip, + collectionId: event.collectionId, + documentId: event.documentId, + createdAt: event.createdAt, + data: event.data, + actor: presentUser(event.actor), + }; + + if (!auditLog) { + delete data.actorIpAddress; + } + + return data; +} diff --git a/server/presenters/index.js b/server/presenters/index.js index 35093842..b51021f3 100644 --- a/server/presenters/index.js +++ b/server/presenters/index.js @@ -2,6 +2,7 @@ import presentUser from './user'; import presentView from './view'; import presentDocument from './document'; +import presentEvent from './event'; import presentRevision from './revision'; import presentCollection from './collection'; import presentApiKey from './apiKey'; @@ -15,6 +16,7 @@ export { presentUser, presentView, presentDocument, + presentEvent, presentRevision, presentCollection, presentApiKey, diff --git a/server/services/backlinks.js b/server/services/backlinks.js index 5fdc1806..0c127171 100644 --- a/server/services/backlinks.js +++ b/server/services/backlinks.js @@ -8,18 +8,18 @@ export default class Backlinks { async on(event: DocumentEvent) { switch (event.name) { case 'documents.publish': { - const document = await Document.findByPk(event.modelId); + const document = await Document.findByPk(event.documentId); const linkIds = parseDocumentIds(document.text); await Promise.all( linkIds.map(async linkId => { const linkedDocument = await Document.findByPk(linkId); - if (linkedDocument.id === event.modelId) return; + if (linkedDocument.id === event.documentId) return; await Backlink.findOrCreate({ where: { documentId: linkedDocument.id, - reverseDocumentId: event.modelId, + reverseDocumentId: event.documentId, }, defaults: { userId: document.lastModifiedById, @@ -32,14 +32,14 @@ export default class Backlinks { } case 'documents.update': { // no-op for now - if (event.autosave) return; + if (event.data.autosave) return; // no-op for drafts - const document = await Document.findByPk(event.modelId); + const document = await Document.findByPk(event.documentId); if (!document.publishedAt) return; const [currentRevision, previsionRevision] = await Revision.findAll({ - where: { documentId: event.modelId }, + where: { documentId: event.documentId }, order: [['createdAt', 'desc']], limit: 2, }); @@ -51,12 +51,12 @@ export default class Backlinks { await Promise.all( addedLinkIds.map(async linkId => { const linkedDocument = await Document.findByPk(linkId); - if (linkedDocument.id === event.modelId) return; + if (linkedDocument.id === event.documentId) return; await Backlink.findOrCreate({ where: { documentId: linkedDocument.id, - reverseDocumentId: event.modelId, + reverseDocumentId: event.documentId, }, defaults: { userId: currentRevision.userId, @@ -71,7 +71,7 @@ export default class Backlinks { await Backlink.destroy({ where: { documentId: document.id, - reverseDocumentId: event.modelId, + reverseDocumentId: event.documentId, }, }); }) @@ -81,12 +81,12 @@ export default class Backlinks { case 'documents.delete': { await Backlink.destroy({ where: { - reverseDocumentId: event.modelId, + reverseDocumentId: event.documentId, }, }); await Backlink.destroy({ where: { - documentId: event.modelId, + documentId: event.documentId, }, }); break; diff --git a/server/services/notifications.js b/server/services/notifications.js index 2f1d97f8..80bfd57c 100644 --- a/server/services/notifications.js +++ b/server/services/notifications.js @@ -18,12 +18,12 @@ export default class Notifications { async documentUpdated(event: DocumentEvent) { // lets not send a notification on every autosave update - if (event.autosave) return; + if (event.data && event.data.autosave) return; // wait until the user has finished editing - if (!event.done) return; + if (event.data && !event.data.done) return; - const document = await Document.findByPk(event.modelId); + const document = await Document.findByPk(event.documentId); if (!document) return; const { collection } = document; @@ -72,7 +72,7 @@ export default class Notifications { } async collectionCreated(event: CollectionEvent) { - const collection = await Collection.findByPk(event.modelId, { + const collection = await Collection.findByPk(event.collectionId, { include: [ { model: User, diff --git a/server/services/slack.js b/server/services/slack.js index 03da5d3f..427af09b 100644 --- a/server/services/slack.js +++ b/server/services/slack.js @@ -58,9 +58,12 @@ export default class Slack { async documentUpdated(event: DocumentEvent) { // lets not send a notification on every autosave update - if (event.autosave) return; + if (event.data && event.data.autosave) return; - const document = await Document.findByPk(event.modelId); + // lets not send a notification on every CMD+S update + if (event.data && !event.data.done) return; + + const document = await Document.findByPk(event.documentId); if (!document) return; // never send information on draft documents diff --git a/server/services/websockets.js b/server/services/websockets.js index 0445f111..87081afb 100644 --- a/server/services/websockets.js +++ b/server/services/websockets.js @@ -17,7 +17,7 @@ export default class Websockets { case 'documents.unpin': case 'documents.update': case 'documents.delete': { - const document = await Document.findByPk(event.modelId, { + const document = await Document.findByPk(event.documentId, { paranoid: false, }); const documents = [await presentDocument(document)]; @@ -32,7 +32,7 @@ export default class Websockets { }); } case 'documents.create': { - const document = await Document.findByPk(event.modelId); + const document = await Document.findByPk(event.documentId); const documents = [await presentDocument(document)]; const collections = [await presentCollection(document.collection)]; @@ -45,19 +45,19 @@ export default class Websockets { case 'documents.star': case 'documents.unstar': { return socketio.to(`user-${event.actorId}`).emit(event.name, { - documentId: event.modelId, + documentId: event.documentId, }); } case 'documents.move': { const documents = await Document.findAll({ where: { - id: event.documentIds, + id: event.data.documentIds, }, paranoid: false, }); const collections = await Collection.findAll({ where: { - id: event.collectionIds, + id: event.data.collectionIds, }, paranoid: false, }); @@ -78,7 +78,7 @@ export default class Websockets { return; } case 'collections.create': { - const collection = await Collection.findByPk(event.modelId, { + const collection = await Collection.findByPk(event.collectionId, { paranoid: false, }); const collections = [await presentCollection(collection)]; @@ -106,7 +106,7 @@ export default class Websockets { } case 'collections.update': case 'collections.delete': { - const collection = await Collection.findByPk(event.modelId, { + const collection = await Collection.findByPk(event.collectionId, { paranoid: false, }); const collections = [await presentCollection(collection)]; @@ -117,12 +117,12 @@ export default class Websockets { }); } case 'collections.add_user': - return socketio.to(`user-${event.modelId}`).emit('join', { + return socketio.to(`user-${event.userId}`).emit('join', { event: event.name, roomId: event.collectionId, }); case 'collections.remove_user': - return socketio.to(`user-${event.modelId}`).emit('leave', { + return socketio.to(`user-${event.userId}`).emit('leave', { event: event.name, roomId: event.collectionId, }); diff --git a/server/test/factories.js b/server/test/factories.js index 55333547..477bea47 100644 --- a/server/test/factories.js +++ b/server/test/factories.js @@ -1,5 +1,5 @@ // @flow -import { Share, Team, User, Document, Collection } from '../models'; +import { Share, Team, User, Event, Document, Collection } from '../models'; import uuid from 'uuid'; let count = 0; @@ -27,6 +27,14 @@ export function buildTeam(overrides: Object = {}) { }); } +export function buildEvent(overrides: Object = {}) { + return Event.create({ + name: 'documents.publish', + ip: '127.0.0.1', + ...overrides, + }); +} + export async function buildUser(overrides: Object = {}) { count++;