refactor: Policies Architecture (#1016)

* add policy serialize method

* Add policies to collection responses

* wip

* test: remove .only

* refactor: Return policies with team and document requests

* store policies on the client

* refactor: drive admin UI from policies
This commit is contained in:
Tom Moor 2019-08-21 21:41:37 -07:00 committed by GitHub
parent cf18b952a4
commit e2b28dfeb7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 194 additions and 19 deletions

View File

@ -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<Props> {
};
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 (
<Sidebar>
@ -125,7 +128,7 @@ class MainSidebar extends React.Component<Props> {
documents.active ? documents.active.isArchived : undefined
}
/>
{user.isAdmin && (
{can.invite && (
<SidebarLink
onClick={this.handleInviteModalOpen}
icon={<PlusIcon />}
@ -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);

View File

@ -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<Props> {
};
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 (
<Sidebar>
@ -71,14 +76,14 @@ class SettingsSidebar extends React.Component<Props> {
</Section>
<Section>
<Header>Team</Header>
{user.isAdmin && (
{can.update && (
<SidebarLink
to="/settings/details"
icon={<TeamIcon />}
label="Details"
/>
)}
{user.isAdmin && (
{can.update && (
<SidebarLink
to="/settings/security"
icon={<PadlockIcon />}
@ -96,14 +101,14 @@ class SettingsSidebar extends React.Component<Props> {
icon={<LinkIcon />}
label="Share Links"
/>
{user.isAdmin && (
{can.auditLog && (
<SidebarLink
to="/settings/events"
icon={<BulletedListIcon />}
label="Audit Log"
/>
)}
{user.isAdmin && (
{can.export && (
<SidebarLink
to="/settings/export"
icon={<DocumentIcon />}
@ -111,7 +116,7 @@ class SettingsSidebar extends React.Component<Props> {
/>
)}
</Section>
{user.isAdmin && (
{can.update && (
<Section>
<Header>Integrations</Header>
<SidebarLink
@ -133,4 +138,4 @@ class SettingsSidebar extends React.Component<Props> {
}
}
export default inject('auth')(SettingsSidebar);
export default inject('auth', 'policies')(SettingsSidebar);

11
app/models/Policy.js Normal file
View File

@ -0,0 +1,11 @@
// @flow
import BaseModel from './BaseModel';
class Policy extends BaseModel {
id: string;
abilities: {
[key: string]: boolean,
};
}
export default Policy;

View File

@ -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 {

View File

@ -37,6 +37,12 @@ export default class BaseStore<T: BaseModel> {
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<T: BaseModel> {
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<T: BaseModel> {
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<T: BaseModel> {
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<T: BaseModel> {
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;
});

View File

@ -320,6 +320,8 @@ export default class DocumentsStore extends BaseStore<Document> {
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<Document> {
const collection = this.getCollectionForDocument(document);
if (collection) collection.refresh();
this.addPolicies(res.policies);
return this.add(res.data);
};

View File

@ -0,0 +1,17 @@
// @flow
import BaseStore from './BaseStore';
import RootStore from './RootStore';
import Policy from 'models/Policy';
export default class PoliciesStore extends BaseStore<Policy> {
actions = [];
constructor(rootStore: RootStore) {
super(rootStore, Policy);
}
abilties(id: string) {
const policy = this.get(id);
return policy ? policy.abilities : {};
}
}

View File

@ -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();

View File

@ -64,10 +64,10 @@ export default class UsersStore extends BaseStore<User> {
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);
});
};
}

View File

@ -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]),
};
});

View File

@ -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,
};
});

View File

@ -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);
});
});

View File

@ -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]),
};
});

View File

@ -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]),
};
});

View File

@ -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;
});

View File

@ -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;

View File

@ -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);
});

View File

@ -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;

View File

@ -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,
};

View File

@ -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),
}));
}