diff --git a/app/components/Sidebar/Main.js b/app/components/Sidebar/Main.js index b8f168c5..30256adf 100644 --- a/app/components/Sidebar/Main.js +++ b/app/components/Sidebar/Main.js @@ -25,12 +25,14 @@ import Bubble from './components/Bubble'; import AuthStore from 'stores/AuthStore'; import DocumentsStore from 'stores/DocumentsStore'; +import PoliciesStore from 'stores/PoliciesStore'; import UiStore from 'stores/UiStore'; import { observable } from 'mobx'; type Props = { auth: AuthStore, documents: DocumentsStore, + policies: PoliciesStore, ui: UiStore, }; @@ -55,11 +57,12 @@ class MainSidebar extends React.Component { }; render() { - const { auth, documents } = this.props; + const { auth, documents, policies } = this.props; const { user, team } = auth; if (!user || !team) return null; const draftDocumentsCount = documents.drafts.length; + const can = policies.abilties(team.id); return ( @@ -125,7 +128,7 @@ class MainSidebar extends React.Component { documents.active ? documents.active.isArchived : undefined } /> - {user.isAdmin && ( + {can.invite && ( } @@ -151,4 +154,4 @@ const Drafts = styled(Flex)` height: 24px; `; -export default inject('documents', 'auth', 'ui')(MainSidebar); +export default inject('documents', 'policies', 'auth', 'ui')(MainSidebar); diff --git a/app/components/Sidebar/Settings.js b/app/components/Sidebar/Settings.js index 37a8af6c..c3c687ad 100644 --- a/app/components/Sidebar/Settings.js +++ b/app/components/Sidebar/Settings.js @@ -23,10 +23,12 @@ import Section from './components/Section'; import Header from './components/Header'; import SidebarLink from './components/SidebarLink'; import HeaderBlock from './components/HeaderBlock'; +import PoliciesStore from 'stores/PoliciesStore'; import AuthStore from 'stores/AuthStore'; type Props = { history: RouterHistory, + policies: PoliciesStore, auth: AuthStore, }; @@ -37,8 +39,11 @@ class SettingsSidebar extends React.Component { }; render() { - const { team, user } = this.props.auth; - if (!team || !user) return null; + const { policies, auth } = this.props; + const { team } = auth; + if (!team) return null; + + const can = policies.abilties(team.id); return ( @@ -71,14 +76,14 @@ class SettingsSidebar extends React.Component {
Team
- {user.isAdmin && ( + {can.update && ( } label="Details" /> )} - {user.isAdmin && ( + {can.update && ( } @@ -96,14 +101,14 @@ class SettingsSidebar extends React.Component { icon={} label="Share Links" /> - {user.isAdmin && ( + {can.auditLog && ( } label="Audit Log" /> )} - {user.isAdmin && ( + {can.export && ( } @@ -111,7 +116,7 @@ class SettingsSidebar extends React.Component { /> )}
- {user.isAdmin && ( + {can.update && (
Integrations
{ } } -export default inject('auth')(SettingsSidebar); +export default inject('auth', 'policies')(SettingsSidebar); diff --git a/app/models/Policy.js b/app/models/Policy.js new file mode 100644 index 00000000..b28bfe1e --- /dev/null +++ b/app/models/Policy.js @@ -0,0 +1,11 @@ +// @flow +import BaseModel from './BaseModel'; + +class Policy extends BaseModel { + id: string; + abilities: { + [key: string]: boolean, + }; +} + +export default Policy; diff --git a/app/stores/AuthStore.js b/app/stores/AuthStore.js index 89f1476f..6f60fb89 100644 --- a/app/stores/AuthStore.js +++ b/app/stores/AuthStore.js @@ -44,6 +44,12 @@ export default class AuthStore { }); } + addPolicies = policies => { + if (policies) { + policies.forEach(policy => this.rootStore.policies.add(policy)); + } + }; + @computed get authenticated(): boolean { return !!this.token; @@ -64,6 +70,7 @@ export default class AuthStore { invariant(res && res.data, 'Auth not available'); runInAction('AuthStore#fetch', () => { + this.addPolicies(res.policies); const { user, team } = res.data; this.user = user; this.team = team; @@ -112,6 +119,7 @@ export default class AuthStore { invariant(res && res.data, 'User response not available'); runInAction('AuthStore#updateUser', () => { + this.addPolicies(res.policies); this.user = res.data; }); } finally { @@ -132,6 +140,7 @@ export default class AuthStore { invariant(res && res.data, 'Team response not available'); runInAction('AuthStore#updateTeam', () => { + this.addPolicies(res.policies); this.team = res.data; }); } finally { diff --git a/app/stores/BaseStore.js b/app/stores/BaseStore.js index 3710f081..e03b2b7e 100644 --- a/app/stores/BaseStore.js +++ b/app/stores/BaseStore.js @@ -37,6 +37,12 @@ export default class BaseStore { this.data.clear(); } + addPolicies = policies => { + if (policies) { + policies.forEach(policy => this.rootStore.policies.add(policy)); + } + }; + @action add = (item: Object): T => { const Model = this.model; @@ -80,6 +86,8 @@ export default class BaseStore { const res = await client.post(`/${this.modelName}s.create`, params); invariant(res && res.data, 'Data should be available'); + + this.addPolicies(res.policies); return this.add(res.data); } finally { this.isSaving = false; @@ -97,6 +105,8 @@ export default class BaseStore { const res = await client.post(`/${this.modelName}s.update`, params); invariant(res && res.data, 'Data should be available'); + + this.addPolicies(res.policies); return this.add(res.data); } finally { this.isSaving = false; @@ -132,6 +142,8 @@ export default class BaseStore { try { const res = await client.post(`/${this.modelName}s.info`, { id }); invariant(res && res.data, 'Data should be available'); + + this.addPolicies(res.policies); return this.add(res.data); } finally { this.isFetching = false; @@ -149,7 +161,9 @@ export default class BaseStore { const res = await client.post(`/${this.modelName}s.list`, params); invariant(res && res.data, 'Data not available'); + runInAction(`list#${this.modelName}`, () => { + this.addPolicies(res.policies); res.data.forEach(this.add); this.isLoaded = true; }); diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js index 2e242925..17343c89 100644 --- a/app/stores/DocumentsStore.js +++ b/app/stores/DocumentsStore.js @@ -320,6 +320,8 @@ export default class DocumentsStore extends BaseStore { shareId: options.shareId, }); invariant(res && res.data, 'Document not available'); + + this.addPolicies(res.policies); this.add(res.data); runInAction('DocumentsStore#fetch', () => { @@ -363,6 +365,7 @@ export default class DocumentsStore extends BaseStore { const collection = this.getCollectionForDocument(document); if (collection) collection.refresh(); + this.addPolicies(res.policies); return this.add(res.data); }; diff --git a/app/stores/PoliciesStore.js b/app/stores/PoliciesStore.js new file mode 100644 index 00000000..831a4389 --- /dev/null +++ b/app/stores/PoliciesStore.js @@ -0,0 +1,17 @@ +// @flow +import BaseStore from './BaseStore'; +import RootStore from './RootStore'; +import Policy from 'models/Policy'; + +export default class PoliciesStore extends BaseStore { + actions = []; + + constructor(rootStore: RootStore) { + super(rootStore, Policy); + } + + abilties(id: string) { + const policy = this.get(id); + return policy ? policy.abilities : {}; + } +} diff --git a/app/stores/RootStore.js b/app/stores/RootStore.js index df49a2b4..2ac35957 100644 --- a/app/stores/RootStore.js +++ b/app/stores/RootStore.js @@ -6,6 +6,7 @@ import DocumentsStore from './DocumentsStore'; import EventsStore from './EventsStore'; import IntegrationsStore from './IntegrationsStore'; import NotificationSettingsStore from './NotificationSettingsStore'; +import PoliciesStore from './PoliciesStore'; import RevisionsStore from './RevisionsStore'; import SharesStore from './SharesStore'; import UiStore from './UiStore'; @@ -20,6 +21,7 @@ export default class RootStore { events: EventsStore; integrations: IntegrationsStore; notificationSettings: NotificationSettingsStore; + policies: PoliciesStore; revisions: RevisionsStore; shares: SharesStore; ui: UiStore; @@ -34,6 +36,7 @@ export default class RootStore { this.events = new EventsStore(this); this.integrations = new IntegrationsStore(this); this.notificationSettings = new NotificationSettingsStore(this); + this.policies = new PoliciesStore(this); this.revisions = new RevisionsStore(this); this.shares = new SharesStore(this); this.ui = new UiStore(); @@ -48,6 +51,7 @@ export default class RootStore { this.events.clear(); this.integrations.clear(); this.notificationSettings.clear(); + this.policies.clear(); this.revisions.clear(); this.shares.clear(); this.users.clear(); diff --git a/app/stores/UsersStore.js b/app/stores/UsersStore.js index d408bdaa..542033d5 100644 --- a/app/stores/UsersStore.js +++ b/app/stores/UsersStore.js @@ -64,10 +64,10 @@ export default class UsersStore extends BaseStore { id: user.id, }); invariant(res && res.data, 'Data should be available'); - const { data } = res; runInAction(`UsersStore#${action}`, () => { - this.add(data); + this.addPolicies(res.policies); + this.add(res.data); }); }; } diff --git a/server/api/auth.js b/server/api/auth.js index 847abc59..2b886dc1 100644 --- a/server/api/auth.js +++ b/server/api/auth.js @@ -1,7 +1,7 @@ // @flow import Router from 'koa-router'; import auth from '../middlewares/authentication'; -import { presentUser, presentTeam } from '../presenters'; +import { presentUser, presentTeam, presentPolicies } from '../presenters'; import { Team } from '../models'; const router = new Router(); @@ -15,6 +15,7 @@ router.post('auth.info', auth(), async ctx => { user: presentUser(user, { includeDetails: true }), team: presentTeam(team), }, + policies: presentPolicies(user, [team]), }; }); diff --git a/server/api/collections.js b/server/api/collections.js index 5f45267a..0bb43b1c 100644 --- a/server/api/collections.js +++ b/server/api/collections.js @@ -3,7 +3,7 @@ import fs from 'fs'; import Router from 'koa-router'; import auth from '../middlewares/authentication'; import pagination from './middlewares/pagination'; -import { presentCollection, presentUser } from '../presenters'; +import { presentCollection, presentUser, presentPolicies } from '../presenters'; import { Collection, CollectionUser, Team, Event, User } from '../models'; import { ValidationError, InvalidRequestError } from '../errors'; import { exportCollections } from '../logistics'; @@ -45,6 +45,7 @@ router.post('collections.create', auth(), async ctx => { ctx.body = { data: await presentCollection(collection), + policies: presentPolicies(user, [collection]), }; }); @@ -52,11 +53,13 @@ router.post('collections.info', auth(), async ctx => { const { id } = ctx.body; ctx.assertUuid(id, 'id is required'); + const user = ctx.state.user; const collection = await Collection.findByPk(id); - authorize(ctx.state.user, 'read', collection); + authorize(user, 'read', collection); ctx.body = { data: await presentCollection(collection), + policies: presentPolicies(user, [collection]), }; }); @@ -243,6 +246,7 @@ router.post('collections.update', auth(), async ctx => { ctx.body = { data: presentCollection(collection), + policies: presentPolicies(user, [collection]), }; }); @@ -263,10 +267,12 @@ router.post('collections.list', auth(), pagination(), async ctx => { const data = await Promise.all( collections.map(async collection => await presentCollection(collection)) ); + const policies = presentPolicies(user, collections); ctx.body = { pagination: ctx.state.pagination, data, + policies, }; }); diff --git a/server/api/collections.test.js b/server/api/collections.test.js index a627e4f8..e273d13a 100644 --- a/server/api/collections.test.js +++ b/server/api/collections.test.js @@ -358,6 +358,7 @@ describe('#collections.create', async () => { expect(res.status).toEqual(200); expect(body.data.id).toBeTruthy(); expect(body.data.name).toBe('Test'); + expect(body.policies.length).toBe(1); }); }); diff --git a/server/api/documents.js b/server/api/documents.js index ac16c2e7..0a152fa7 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -8,6 +8,7 @@ import { presentDocument, presentCollection, presentRevision, + presentPolicies, } from '../presenters'; import { Collection, @@ -88,9 +89,12 @@ router.post('documents.list', auth(), pagination(), async ctx => { documents.map(document => presentDocument(document)) ); + const policies = presentPolicies(user, documents); + ctx.body = { pagination: ctx.state.pagination, data, + policies, }; }); @@ -123,9 +127,12 @@ router.post('documents.pinned', auth(), pagination(), async ctx => { documents.map(document => presentDocument(document)) ); + const policies = presentPolicies(user, documents); + ctx.body = { pagination: ctx.state.pagination, data, + policies, }; }); @@ -154,9 +161,12 @@ router.post('documents.archived', auth(), pagination(), async ctx => { documents.map(document => presentDocument(document)) ); + const policies = presentPolicies(user, documents); + ctx.body = { pagination: ctx.state.pagination, data, + policies, }; }); @@ -191,13 +201,17 @@ router.post('documents.viewed', auth(), pagination(), async ctx => { limit: ctx.state.pagination.limit, }); + const documents = views.map(view => view.document); const data = await Promise.all( - views.map(view => presentDocument(view.document)) + documents.map(document => presentDocument(document)) ); + const policies = presentPolicies(user, documents); + ctx.body = { pagination: ctx.state.pagination, data, + policies, }; }); @@ -234,13 +248,17 @@ router.post('documents.starred', auth(), pagination(), async ctx => { limit: ctx.state.pagination.limit, }); + const documents = stars.map(star => star.document); const data = await Promise.all( - stars.map(star => presentDocument(star.document)) + documents.map(document => presentDocument(document)) ); + const policies = presentPolicies(user, documents); + ctx.body = { pagination: ctx.state.pagination, data, + policies, }; }); @@ -266,9 +284,12 @@ router.post('documents.drafts', auth(), pagination(), async ctx => { documents.map(document => presentDocument(document)) ); + const policies = presentPolicies(user, documents); + ctx.body = { pagination: ctx.state.pagination, data, + policies, }; }); @@ -311,6 +332,7 @@ router.post('documents.info', auth({ required: false }), async ctx => { ctx.body = { data: await presentDocument(document, { isPublic }), + policies: isPublic ? undefined : presentPolicies(user, [document]), }; }); @@ -404,6 +426,7 @@ router.post('documents.restore', auth(), async ctx => { ctx.body = { data: await presentDocument(document), + policies: presentPolicies(user, [document]), }; }); @@ -443,6 +466,7 @@ router.post('documents.search', auth(), pagination(), async ctx => { limit, }); + const documents = results.map(result => result.document); const data = await Promise.all( results.map(async result => { const document = await presentDocument(result.document); @@ -450,9 +474,12 @@ router.post('documents.search', auth(), pagination(), async ctx => { }) ); + const policies = presentPolicies(user, documents); + ctx.body = { pagination: ctx.state.pagination, data, + policies, }; }); @@ -479,6 +506,7 @@ router.post('documents.pin', auth(), async ctx => { ctx.body = { data: await presentDocument(document), + policies: presentPolicies(user, [document]), }; }); @@ -505,6 +533,7 @@ router.post('documents.unpin', auth(), async ctx => { ctx.body = { data: await presentDocument(document), + policies: presentPolicies(user, [document]), }; }); @@ -637,6 +666,7 @@ router.post('documents.create', auth(), async ctx => { ctx.body = { data: await presentDocument(document), + policies: presentPolicies(user, [document]), }; }); @@ -719,6 +749,7 @@ router.post('documents.update', auth(), async ctx => { ctx.body = { data: await presentDocument(document), + policies: presentPolicies(user, [document]), }; }); @@ -774,6 +805,7 @@ router.post('documents.move', auth(), async ctx => { collections: await Promise.all( collections.map(collection => presentCollection(collection)) ), + policies: presentPolicies(user, documents), }, }; }); @@ -800,6 +832,7 @@ router.post('documents.archive', auth(), async ctx => { ctx.body = { data: await presentDocument(document), + policies: presentPolicies(user, [document]), }; }); diff --git a/server/api/team.js b/server/api/team.js index feffcc31..cd9d75ce 100644 --- a/server/api/team.js +++ b/server/api/team.js @@ -4,7 +4,7 @@ import { Team } from '../models'; import { publicS3Endpoint } from '../utils/s3'; import auth from '../middlewares/authentication'; -import { presentTeam } from '../presenters'; +import { presentTeam, presentPolicies } from '../presenters'; import policy from '../policies'; const { authorize } = policy; @@ -32,6 +32,7 @@ router.post('team.update', auth(), async ctx => { ctx.body = { data: presentTeam(team), + policies: presentPolicies(user, [team]), }; }); diff --git a/server/policies/document.js b/server/policies/document.js index 12f324d0..20695e33 100644 --- a/server/policies/document.js +++ b/server/policies/document.js @@ -28,6 +28,7 @@ allow(User, 'archive', Document, (user, document) => { if (cannot(user, 'read', document.collection)) return false; } if (!document.publishedAt) return false; + if (document.archivedAt) return false; return user.teamId === document.teamId; }); diff --git a/server/policies/index.js b/server/policies/index.js index 1668774b..7a89e6aa 100644 --- a/server/policies/index.js +++ b/server/policies/index.js @@ -1,4 +1,5 @@ // @flow +import { Team, User, Collection, Document } from '../models'; import policy from './policy'; import './apiKey'; import './collection'; @@ -9,4 +10,36 @@ import './share'; import './user'; import './team'; +const { can, abilities } = policy; + +type Policy = { + [key: string]: boolean, +}; + +/* +* Given a user and a model – output an object which describes the actions the +* user may take against the model. This serialized policy is used for testing +* and sent in API responses to allow clients to adjust which UI is displayed. +*/ +export function serialize( + model: User, + target: Team | Collection | Document +): Policy { + let output = {}; + + abilities.forEach(ability => { + if (model instanceof ability.model && target instanceof ability.target) { + let response = true; + try { + response = can(model, ability.action, target); + } catch (err) { + response = false; + } + output[ability.action] = response; + } + }); + + return output; +} + export default policy; diff --git a/server/policies/index.test.js b/server/policies/index.test.js new file mode 100644 index 00000000..7b4fa9cb --- /dev/null +++ b/server/policies/index.test.js @@ -0,0 +1,13 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ +import { flushdb } from '../test/support'; +import { buildUser } from '../test/factories'; +import { serialize } from './index'; + +beforeEach(flushdb); + +it('should serialize policy', async () => { + const user = await buildUser(); + const response = serialize(user, user); + expect(response.update).toEqual(true); + expect(response.delete).toEqual(true); +}); diff --git a/server/policies/team.js b/server/policies/team.js index bba7f0ae..b0e6d5d9 100644 --- a/server/policies/team.js +++ b/server/policies/team.js @@ -17,6 +17,11 @@ allow(User, 'auditLog', Team, user => { return false; }); +allow(User, 'invite', 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/index.js b/server/presenters/index.js index b51021f3..17a0b074 100644 --- a/server/presenters/index.js +++ b/server/presenters/index.js @@ -11,6 +11,7 @@ import presentTeam from './team'; import presentIntegration from './integration'; import presentNotificationSetting from './notificationSetting'; import presentSlackAttachment from './slackAttachment'; +import presentPolicies from './policy'; export { presentUser, @@ -25,4 +26,5 @@ export { presentIntegration, presentNotificationSetting, presentSlackAttachment, + presentPolicies, }; diff --git a/server/presenters/policy.js b/server/presenters/policy.js new file mode 100644 index 00000000..46a87701 --- /dev/null +++ b/server/presenters/policy.js @@ -0,0 +1,13 @@ +// @flow +import { User } from '../models'; + +type Policy = { id: string, abilities: { [key: string]: boolean } }; + +export default function present(user: User, objects: Object[]): Policy[] { + const { serialize } = require('../policies'); + + return objects.map(object => ({ + id: object.id, + abilities: serialize(user, object), + })); +}