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
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 AuthStore from 'stores/AuthStore';
import DocumentsStore from 'stores/DocumentsStore'; import DocumentsStore from 'stores/DocumentsStore';
import PoliciesStore from 'stores/PoliciesStore';
import UiStore from 'stores/UiStore'; import UiStore from 'stores/UiStore';
import { observable } from 'mobx'; import { observable } from 'mobx';
type Props = { type Props = {
auth: AuthStore, auth: AuthStore,
documents: DocumentsStore, documents: DocumentsStore,
policies: PoliciesStore,
ui: UiStore, ui: UiStore,
}; };
@ -55,11 +57,12 @@ class MainSidebar extends React.Component<Props> {
}; };
render() { render() {
const { auth, documents } = this.props; const { auth, documents, policies } = this.props;
const { user, team } = auth; const { user, team } = auth;
if (!user || !team) return null; if (!user || !team) return null;
const draftDocumentsCount = documents.drafts.length; const draftDocumentsCount = documents.drafts.length;
const can = policies.abilties(team.id);
return ( return (
<Sidebar> <Sidebar>
@ -125,7 +128,7 @@ class MainSidebar extends React.Component<Props> {
documents.active ? documents.active.isArchived : undefined documents.active ? documents.active.isArchived : undefined
} }
/> />
{user.isAdmin && ( {can.invite && (
<SidebarLink <SidebarLink
onClick={this.handleInviteModalOpen} onClick={this.handleInviteModalOpen}
icon={<PlusIcon />} icon={<PlusIcon />}
@ -151,4 +154,4 @@ const Drafts = styled(Flex)`
height: 24px; 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 Header from './components/Header';
import SidebarLink from './components/SidebarLink'; import SidebarLink from './components/SidebarLink';
import HeaderBlock from './components/HeaderBlock'; import HeaderBlock from './components/HeaderBlock';
import PoliciesStore from 'stores/PoliciesStore';
import AuthStore from 'stores/AuthStore'; import AuthStore from 'stores/AuthStore';
type Props = { type Props = {
history: RouterHistory, history: RouterHistory,
policies: PoliciesStore,
auth: AuthStore, auth: AuthStore,
}; };
@ -37,8 +39,11 @@ class SettingsSidebar extends React.Component<Props> {
}; };
render() { render() {
const { team, user } = this.props.auth; const { policies, auth } = this.props;
if (!team || !user) return null; const { team } = auth;
if (!team) return null;
const can = policies.abilties(team.id);
return ( return (
<Sidebar> <Sidebar>
@ -71,14 +76,14 @@ class SettingsSidebar extends React.Component<Props> {
</Section> </Section>
<Section> <Section>
<Header>Team</Header> <Header>Team</Header>
{user.isAdmin && ( {can.update && (
<SidebarLink <SidebarLink
to="/settings/details" to="/settings/details"
icon={<TeamIcon />} icon={<TeamIcon />}
label="Details" label="Details"
/> />
)} )}
{user.isAdmin && ( {can.update && (
<SidebarLink <SidebarLink
to="/settings/security" to="/settings/security"
icon={<PadlockIcon />} icon={<PadlockIcon />}
@ -96,14 +101,14 @@ class SettingsSidebar extends React.Component<Props> {
icon={<LinkIcon />} icon={<LinkIcon />}
label="Share Links" label="Share Links"
/> />
{user.isAdmin && ( {can.auditLog && (
<SidebarLink <SidebarLink
to="/settings/events" to="/settings/events"
icon={<BulletedListIcon />} icon={<BulletedListIcon />}
label="Audit Log" label="Audit Log"
/> />
)} )}
{user.isAdmin && ( {can.export && (
<SidebarLink <SidebarLink
to="/settings/export" to="/settings/export"
icon={<DocumentIcon />} icon={<DocumentIcon />}
@ -111,7 +116,7 @@ class SettingsSidebar extends React.Component<Props> {
/> />
)} )}
</Section> </Section>
{user.isAdmin && ( {can.update && (
<Section> <Section>
<Header>Integrations</Header> <Header>Integrations</Header>
<SidebarLink <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 @computed
get authenticated(): boolean { get authenticated(): boolean {
return !!this.token; return !!this.token;
@ -64,6 +70,7 @@ export default class AuthStore {
invariant(res && res.data, 'Auth not available'); invariant(res && res.data, 'Auth not available');
runInAction('AuthStore#fetch', () => { runInAction('AuthStore#fetch', () => {
this.addPolicies(res.policies);
const { user, team } = res.data; const { user, team } = res.data;
this.user = user; this.user = user;
this.team = team; this.team = team;
@ -112,6 +119,7 @@ export default class AuthStore {
invariant(res && res.data, 'User response not available'); invariant(res && res.data, 'User response not available');
runInAction('AuthStore#updateUser', () => { runInAction('AuthStore#updateUser', () => {
this.addPolicies(res.policies);
this.user = res.data; this.user = res.data;
}); });
} finally { } finally {
@ -132,6 +140,7 @@ export default class AuthStore {
invariant(res && res.data, 'Team response not available'); invariant(res && res.data, 'Team response not available');
runInAction('AuthStore#updateTeam', () => { runInAction('AuthStore#updateTeam', () => {
this.addPolicies(res.policies);
this.team = res.data; this.team = res.data;
}); });
} finally { } finally {

View File

@ -37,6 +37,12 @@ export default class BaseStore<T: BaseModel> {
this.data.clear(); this.data.clear();
} }
addPolicies = policies => {
if (policies) {
policies.forEach(policy => this.rootStore.policies.add(policy));
}
};
@action @action
add = (item: Object): T => { add = (item: Object): T => {
const Model = this.model; const Model = this.model;
@ -80,6 +86,8 @@ export default class BaseStore<T: BaseModel> {
const res = await client.post(`/${this.modelName}s.create`, params); const res = await client.post(`/${this.modelName}s.create`, params);
invariant(res && res.data, 'Data should be available'); invariant(res && res.data, 'Data should be available');
this.addPolicies(res.policies);
return this.add(res.data); return this.add(res.data);
} finally { } finally {
this.isSaving = false; this.isSaving = false;
@ -97,6 +105,8 @@ export default class BaseStore<T: BaseModel> {
const res = await client.post(`/${this.modelName}s.update`, params); const res = await client.post(`/${this.modelName}s.update`, params);
invariant(res && res.data, 'Data should be available'); invariant(res && res.data, 'Data should be available');
this.addPolicies(res.policies);
return this.add(res.data); return this.add(res.data);
} finally { } finally {
this.isSaving = false; this.isSaving = false;
@ -132,6 +142,8 @@ export default class BaseStore<T: BaseModel> {
try { try {
const res = await client.post(`/${this.modelName}s.info`, { id }); const res = await client.post(`/${this.modelName}s.info`, { id });
invariant(res && res.data, 'Data should be available'); invariant(res && res.data, 'Data should be available');
this.addPolicies(res.policies);
return this.add(res.data); return this.add(res.data);
} finally { } finally {
this.isFetching = false; this.isFetching = false;
@ -149,7 +161,9 @@ export default class BaseStore<T: BaseModel> {
const res = await client.post(`/${this.modelName}s.list`, params); const res = await client.post(`/${this.modelName}s.list`, params);
invariant(res && res.data, 'Data not available'); invariant(res && res.data, 'Data not available');
runInAction(`list#${this.modelName}`, () => { runInAction(`list#${this.modelName}`, () => {
this.addPolicies(res.policies);
res.data.forEach(this.add); res.data.forEach(this.add);
this.isLoaded = true; this.isLoaded = true;
}); });

View File

@ -320,6 +320,8 @@ export default class DocumentsStore extends BaseStore<Document> {
shareId: options.shareId, shareId: options.shareId,
}); });
invariant(res && res.data, 'Document not available'); invariant(res && res.data, 'Document not available');
this.addPolicies(res.policies);
this.add(res.data); this.add(res.data);
runInAction('DocumentsStore#fetch', () => { runInAction('DocumentsStore#fetch', () => {
@ -363,6 +365,7 @@ export default class DocumentsStore extends BaseStore<Document> {
const collection = this.getCollectionForDocument(document); const collection = this.getCollectionForDocument(document);
if (collection) collection.refresh(); if (collection) collection.refresh();
this.addPolicies(res.policies);
return this.add(res.data); 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 EventsStore from './EventsStore';
import IntegrationsStore from './IntegrationsStore'; import IntegrationsStore from './IntegrationsStore';
import NotificationSettingsStore from './NotificationSettingsStore'; import NotificationSettingsStore from './NotificationSettingsStore';
import PoliciesStore from './PoliciesStore';
import RevisionsStore from './RevisionsStore'; import RevisionsStore from './RevisionsStore';
import SharesStore from './SharesStore'; import SharesStore from './SharesStore';
import UiStore from './UiStore'; import UiStore from './UiStore';
@ -20,6 +21,7 @@ export default class RootStore {
events: EventsStore; events: EventsStore;
integrations: IntegrationsStore; integrations: IntegrationsStore;
notificationSettings: NotificationSettingsStore; notificationSettings: NotificationSettingsStore;
policies: PoliciesStore;
revisions: RevisionsStore; revisions: RevisionsStore;
shares: SharesStore; shares: SharesStore;
ui: UiStore; ui: UiStore;
@ -34,6 +36,7 @@ export default class RootStore {
this.events = new EventsStore(this); this.events = new EventsStore(this);
this.integrations = new IntegrationsStore(this); this.integrations = new IntegrationsStore(this);
this.notificationSettings = new NotificationSettingsStore(this); this.notificationSettings = new NotificationSettingsStore(this);
this.policies = new PoliciesStore(this);
this.revisions = new RevisionsStore(this); this.revisions = new RevisionsStore(this);
this.shares = new SharesStore(this); this.shares = new SharesStore(this);
this.ui = new UiStore(); this.ui = new UiStore();
@ -48,6 +51,7 @@ export default class RootStore {
this.events.clear(); this.events.clear();
this.integrations.clear(); this.integrations.clear();
this.notificationSettings.clear(); this.notificationSettings.clear();
this.policies.clear();
this.revisions.clear(); this.revisions.clear();
this.shares.clear(); this.shares.clear();
this.users.clear(); this.users.clear();

View File

@ -64,10 +64,10 @@ export default class UsersStore extends BaseStore<User> {
id: user.id, id: user.id,
}); });
invariant(res && res.data, 'Data should be available'); invariant(res && res.data, 'Data should be available');
const { data } = res;
runInAction(`UsersStore#${action}`, () => { runInAction(`UsersStore#${action}`, () => {
this.add(data); this.addPolicies(res.policies);
this.add(res.data);
}); });
}; };
} }

View File

@ -1,7 +1,7 @@
// @flow // @flow
import Router from 'koa-router'; import Router from 'koa-router';
import auth from '../middlewares/authentication'; import auth from '../middlewares/authentication';
import { presentUser, presentTeam } from '../presenters'; import { presentUser, presentTeam, presentPolicies } from '../presenters';
import { Team } from '../models'; import { Team } from '../models';
const router = new Router(); const router = new Router();
@ -15,6 +15,7 @@ router.post('auth.info', auth(), async ctx => {
user: presentUser(user, { includeDetails: true }), user: presentUser(user, { includeDetails: true }),
team: presentTeam(team), team: presentTeam(team),
}, },
policies: presentPolicies(user, [team]),
}; };
}); });

View File

@ -3,7 +3,7 @@ import fs from 'fs';
import Router from 'koa-router'; import Router from 'koa-router';
import auth from '../middlewares/authentication'; import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination'; 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 { Collection, CollectionUser, Team, Event, User } from '../models';
import { ValidationError, InvalidRequestError } from '../errors'; import { ValidationError, InvalidRequestError } from '../errors';
import { exportCollections } from '../logistics'; import { exportCollections } from '../logistics';
@ -45,6 +45,7 @@ router.post('collections.create', auth(), async ctx => {
ctx.body = { ctx.body = {
data: await presentCollection(collection), data: await presentCollection(collection),
policies: presentPolicies(user, [collection]),
}; };
}); });
@ -52,11 +53,13 @@ router.post('collections.info', auth(), async ctx => {
const { id } = ctx.body; const { id } = ctx.body;
ctx.assertUuid(id, 'id is required'); ctx.assertUuid(id, 'id is required');
const user = ctx.state.user;
const collection = await Collection.findByPk(id); const collection = await Collection.findByPk(id);
authorize(ctx.state.user, 'read', collection); authorize(user, 'read', collection);
ctx.body = { ctx.body = {
data: await presentCollection(collection), data: await presentCollection(collection),
policies: presentPolicies(user, [collection]),
}; };
}); });
@ -243,6 +246,7 @@ router.post('collections.update', auth(), async ctx => {
ctx.body = { ctx.body = {
data: presentCollection(collection), data: presentCollection(collection),
policies: presentPolicies(user, [collection]),
}; };
}); });
@ -263,10 +267,12 @@ router.post('collections.list', auth(), pagination(), async ctx => {
const data = await Promise.all( const data = await Promise.all(
collections.map(async collection => await presentCollection(collection)) collections.map(async collection => await presentCollection(collection))
); );
const policies = presentPolicies(user, collections);
ctx.body = { ctx.body = {
pagination: ctx.state.pagination, pagination: ctx.state.pagination,
data, data,
policies,
}; };
}); });

View File

@ -358,6 +358,7 @@ describe('#collections.create', async () => {
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(body.data.id).toBeTruthy(); expect(body.data.id).toBeTruthy();
expect(body.data.name).toBe('Test'); expect(body.data.name).toBe('Test');
expect(body.policies.length).toBe(1);
}); });
}); });

View File

@ -8,6 +8,7 @@ import {
presentDocument, presentDocument,
presentCollection, presentCollection,
presentRevision, presentRevision,
presentPolicies,
} from '../presenters'; } from '../presenters';
import { import {
Collection, Collection,
@ -88,9 +89,12 @@ router.post('documents.list', auth(), pagination(), async ctx => {
documents.map(document => presentDocument(document)) documents.map(document => presentDocument(document))
); );
const policies = presentPolicies(user, documents);
ctx.body = { ctx.body = {
pagination: ctx.state.pagination, pagination: ctx.state.pagination,
data, data,
policies,
}; };
}); });
@ -123,9 +127,12 @@ router.post('documents.pinned', auth(), pagination(), async ctx => {
documents.map(document => presentDocument(document)) documents.map(document => presentDocument(document))
); );
const policies = presentPolicies(user, documents);
ctx.body = { ctx.body = {
pagination: ctx.state.pagination, pagination: ctx.state.pagination,
data, data,
policies,
}; };
}); });
@ -154,9 +161,12 @@ router.post('documents.archived', auth(), pagination(), async ctx => {
documents.map(document => presentDocument(document)) documents.map(document => presentDocument(document))
); );
const policies = presentPolicies(user, documents);
ctx.body = { ctx.body = {
pagination: ctx.state.pagination, pagination: ctx.state.pagination,
data, data,
policies,
}; };
}); });
@ -191,13 +201,17 @@ router.post('documents.viewed', auth(), pagination(), async ctx => {
limit: ctx.state.pagination.limit, limit: ctx.state.pagination.limit,
}); });
const documents = views.map(view => view.document);
const data = await Promise.all( const data = await Promise.all(
views.map(view => presentDocument(view.document)) documents.map(document => presentDocument(document))
); );
const policies = presentPolicies(user, documents);
ctx.body = { ctx.body = {
pagination: ctx.state.pagination, pagination: ctx.state.pagination,
data, data,
policies,
}; };
}); });
@ -234,13 +248,17 @@ router.post('documents.starred', auth(), pagination(), async ctx => {
limit: ctx.state.pagination.limit, limit: ctx.state.pagination.limit,
}); });
const documents = stars.map(star => star.document);
const data = await Promise.all( const data = await Promise.all(
stars.map(star => presentDocument(star.document)) documents.map(document => presentDocument(document))
); );
const policies = presentPolicies(user, documents);
ctx.body = { ctx.body = {
pagination: ctx.state.pagination, pagination: ctx.state.pagination,
data, data,
policies,
}; };
}); });
@ -266,9 +284,12 @@ router.post('documents.drafts', auth(), pagination(), async ctx => {
documents.map(document => presentDocument(document)) documents.map(document => presentDocument(document))
); );
const policies = presentPolicies(user, documents);
ctx.body = { ctx.body = {
pagination: ctx.state.pagination, pagination: ctx.state.pagination,
data, data,
policies,
}; };
}); });
@ -311,6 +332,7 @@ router.post('documents.info', auth({ required: false }), async ctx => {
ctx.body = { ctx.body = {
data: await presentDocument(document, { isPublic }), data: await presentDocument(document, { isPublic }),
policies: isPublic ? undefined : presentPolicies(user, [document]),
}; };
}); });
@ -404,6 +426,7 @@ router.post('documents.restore', auth(), async ctx => {
ctx.body = { ctx.body = {
data: await presentDocument(document), data: await presentDocument(document),
policies: presentPolicies(user, [document]),
}; };
}); });
@ -443,6 +466,7 @@ router.post('documents.search', auth(), pagination(), async ctx => {
limit, limit,
}); });
const documents = results.map(result => result.document);
const data = await Promise.all( const data = await Promise.all(
results.map(async result => { results.map(async result => {
const document = await presentDocument(result.document); 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 = { ctx.body = {
pagination: ctx.state.pagination, pagination: ctx.state.pagination,
data, data,
policies,
}; };
}); });
@ -479,6 +506,7 @@ router.post('documents.pin', auth(), async ctx => {
ctx.body = { ctx.body = {
data: await presentDocument(document), data: await presentDocument(document),
policies: presentPolicies(user, [document]),
}; };
}); });
@ -505,6 +533,7 @@ router.post('documents.unpin', auth(), async ctx => {
ctx.body = { ctx.body = {
data: await presentDocument(document), data: await presentDocument(document),
policies: presentPolicies(user, [document]),
}; };
}); });
@ -637,6 +666,7 @@ router.post('documents.create', auth(), async ctx => {
ctx.body = { ctx.body = {
data: await presentDocument(document), data: await presentDocument(document),
policies: presentPolicies(user, [document]),
}; };
}); });
@ -719,6 +749,7 @@ router.post('documents.update', auth(), async ctx => {
ctx.body = { ctx.body = {
data: await presentDocument(document), data: await presentDocument(document),
policies: presentPolicies(user, [document]),
}; };
}); });
@ -774,6 +805,7 @@ router.post('documents.move', auth(), async ctx => {
collections: await Promise.all( collections: await Promise.all(
collections.map(collection => presentCollection(collection)) collections.map(collection => presentCollection(collection))
), ),
policies: presentPolicies(user, documents),
}, },
}; };
}); });
@ -800,6 +832,7 @@ router.post('documents.archive', auth(), async ctx => {
ctx.body = { ctx.body = {
data: await presentDocument(document), data: await presentDocument(document),
policies: presentPolicies(user, [document]),
}; };
}); });

View File

@ -4,7 +4,7 @@ import { Team } from '../models';
import { publicS3Endpoint } from '../utils/s3'; import { publicS3Endpoint } from '../utils/s3';
import auth from '../middlewares/authentication'; import auth from '../middlewares/authentication';
import { presentTeam } from '../presenters'; import { presentTeam, presentPolicies } from '../presenters';
import policy from '../policies'; import policy from '../policies';
const { authorize } = policy; const { authorize } = policy;
@ -32,6 +32,7 @@ router.post('team.update', auth(), async ctx => {
ctx.body = { ctx.body = {
data: presentTeam(team), 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 (cannot(user, 'read', document.collection)) return false;
} }
if (!document.publishedAt) return false; if (!document.publishedAt) return false;
if (document.archivedAt) return false;
return user.teamId === document.teamId; return user.teamId === document.teamId;
}); });

View File

@ -1,4 +1,5 @@
// @flow // @flow
import { Team, User, Collection, Document } from '../models';
import policy from './policy'; import policy from './policy';
import './apiKey'; import './apiKey';
import './collection'; import './collection';
@ -9,4 +10,36 @@ import './share';
import './user'; import './user';
import './team'; 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; 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; return false;
}); });
allow(User, 'invite', Team, user => {
if (user.isAdmin) return true;
return false;
});
allow(User, ['update', 'export'], Team, (user, team) => { allow(User, ['update', 'export'], Team, (user, team) => {
if (!team || user.teamId !== team.id) return false; if (!team || user.teamId !== team.id) return false;
if (user.isAdmin) return true; if (user.isAdmin) return true;

View File

@ -11,6 +11,7 @@ import presentTeam from './team';
import presentIntegration from './integration'; import presentIntegration from './integration';
import presentNotificationSetting from './notificationSetting'; import presentNotificationSetting from './notificationSetting';
import presentSlackAttachment from './slackAttachment'; import presentSlackAttachment from './slackAttachment';
import presentPolicies from './policy';
export { export {
presentUser, presentUser,
@ -25,4 +26,5 @@ export {
presentIntegration, presentIntegration,
presentNotificationSetting, presentNotificationSetting,
presentSlackAttachment, 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),
}));
}