feat: Adds route-level role filtering. (#3734)

* feat: Adds route-level role filtering. Another layer in the onion of security and performance

* fix: Regression in authentication middleware
This commit is contained in:
Tom Moor
2022-07-05 21:26:49 +02:00
committed by GitHub
parent c6fdffba77
commit 831df67358
12 changed files with 539 additions and 359 deletions

View File

@@ -10,6 +10,7 @@ import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text"; import Text from "~/components/Text";
import { inviteUser } from "~/actions/definitions/users"; import { inviteUser } from "~/actions/definitions/users";
import useCurrentTeam from "~/hooks/useCurrentTeam"; import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy"; import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import OrganizationMenu from "~/menus/OrganizationMenu"; import OrganizationMenu from "~/menus/OrganizationMenu";
@@ -34,12 +35,15 @@ function AppSidebar() {
const { t } = useTranslation(); const { t } = useTranslation();
const { documents } = useStores(); const { documents } = useStores();
const team = useCurrentTeam(); const team = useCurrentTeam();
const user = useCurrentUser();
const can = usePolicy(team.id); const can = usePolicy(team.id);
React.useEffect(() => { React.useEffect(() => {
documents.fetchDrafts(); if (!user.isViewer) {
documents.fetchTemplates(); documents.fetchDrafts();
}, [documents]); documents.fetchTemplates();
}
}, [documents, user.isViewer]);
const [dndArea, setDndArea] = React.useState(); const [dndArea, setDndArea] = React.useState();
const handleSidebarRef = React.useCallback((node) => setDndArea(node), []); const handleSidebarRef = React.useCallback((node) => setDndArea(node), []);

View File

@@ -1,3 +1,4 @@
import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { Switch, Redirect, RouteComponentProps } from "react-router-dom"; import { Switch, Redirect, RouteComponentProps } from "react-router-dom";
import Archive from "~/scenes/Archive"; import Archive from "~/scenes/Archive";
@@ -11,6 +12,8 @@ import CenteredContent from "~/components/CenteredContent";
import PlaceholderDocument from "~/components/PlaceholderDocument"; import PlaceholderDocument from "~/components/PlaceholderDocument";
import Route from "~/components/ProfiledRoute"; import Route from "~/components/ProfiledRoute";
import SocketProvider from "~/components/SocketProvider"; import SocketProvider from "~/components/SocketProvider";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import { matchDocumentSlug as slug } from "~/utils/routeHelpers"; import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
const SettingsRoutes = React.lazy( const SettingsRoutes = React.lazy(
@@ -59,7 +62,10 @@ const RedirectDocument = ({
/> />
); );
export default function AuthenticatedRoutes() { function AuthenticatedRoutes() {
const team = useCurrentTeam();
const can = usePolicy(team.id);
return ( return (
<SocketProvider> <SocketProvider>
<Layout> <Layout>
@@ -71,14 +77,24 @@ export default function AuthenticatedRoutes() {
} }
> >
<Switch> <Switch>
{can.createDocument && (
<Route exact path="/templates" component={Templates} />
)}
{can.createDocument && (
<Route exact path="/templates/:sort" component={Templates} />
)}
{can.createDocument && (
<Route exact path="/drafts" component={Drafts} />
)}
{can.createDocument && (
<Route exact path="/archive" component={Archive} />
)}
{can.createDocument && (
<Route exact path="/trash" component={Trash} />
)}
<Redirect from="/dashboard" to="/home" /> <Redirect from="/dashboard" to="/home" />
<Route path="/home/:tab" component={Home} /> <Route path="/home/:tab" component={Home} />
<Route path="/home" component={Home} /> <Route path="/home" component={Home} />
<Route exact path="/templates" component={Templates} />
<Route exact path="/templates/:sort" component={Templates} />
<Route exact path="/drafts" component={Drafts} />
<Route exact path="/archive" component={Archive} />
<Route exact path="/trash" component={Trash} />
<Redirect exact from="/starred" to="/home" /> <Redirect exact from="/starred" to="/home" />
<Redirect exact from="/collections/*" to="/collection/*" /> <Redirect exact from="/collections/*" to="/collection/*" />
<Route exact path="/collection/:id/new" component={DocumentNew} /> <Route exact path="/collection/:id/new" component={DocumentNew} />
@@ -103,3 +119,5 @@ export default function AuthenticatedRoutes() {
</SocketProvider> </SocketProvider>
); );
} }
export default observer(AuthenticatedRoutes);

View File

@@ -1,15 +1,28 @@
import { Next } from "koa"; import { Next } from "koa";
import Logger from "@server/logging/Logger";
import tracer, { APM } from "@server/logging/tracing"; import tracer, { APM } from "@server/logging/tracing";
import { User, Team, ApiKey } from "@server/models"; import { User, Team, ApiKey } from "@server/models";
import { getUserForJWT } from "@server/utils/jwt"; import { getUserForJWT } from "@server/utils/jwt";
import { AuthenticationError, UserSuspendedError } from "../errors"; import {
import { ContextWithState } from "../types"; AuthenticationError,
AuthorizationError,
UserSuspendedError,
} from "../errors";
import { ContextWithState, AuthenticationTypes } from "../types";
export default function auth( type AuthenticationOptions = {
options: { /* An admin user role is required to access the route */
required?: boolean; admin?: boolean;
} = {} /* A member or admin user role is required to access the route */
) { member?: boolean;
/**
* Authentication is parsed, but optional. Note that if a token is provided
* in the request it must be valid or the requst will be rejected.
*/
optional?: boolean;
};
export default function auth(options: AuthenticationOptions = {}) {
return async function authMiddleware(ctx: ContextWithState, next: Next) { return async function authMiddleware(ctx: ContextWithState, next: Next) {
let token; let token;
const authorizationHeader = ctx.request.get("authorization"); const authorizationHeader = ctx.request.get("authorization");
@@ -29,8 +42,11 @@ export default function auth(
`Bad Authorization header format. Format is "Authorization: Bearer <token>"` `Bad Authorization header format. Format is "Authorization: Bearer <token>"`
); );
} }
// @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. } else if (
} else if (ctx.body && ctx.body.token) { ctx.body &&
typeof ctx.body === "object" &&
"token" in ctx.body
) {
// @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'.
token = ctx.body.token; token = ctx.body.token;
} else if (ctx.request.query.token) { } else if (ctx.request.query.token) {
@@ -39,15 +55,15 @@ export default function auth(
token = ctx.cookies.get("accessToken"); token = ctx.cookies.get("accessToken");
} }
if (!token && options.required !== false) { if (!token && options.optional !== true) {
throw AuthenticationError("Authentication required"); throw AuthenticationError("Authentication required");
} }
let user; let user: User | null | undefined;
if (token) { if (token) {
if (String(token).match(/^[\w]{38}$/)) { if (String(token).match(/^[\w]{38}$/)) {
ctx.state.authType = "api"; ctx.state.authType = AuthenticationTypes.API;
let apiKey; let apiKey;
try { try {
@@ -78,7 +94,7 @@ export default function auth(
throw AuthenticationError("Invalid API key"); throw AuthenticationError("Invalid API key");
} }
} else { } else {
ctx.state.authType = "app"; ctx.state.authType = AuthenticationTypes.APP;
user = await getUserForJWT(String(token)); user = await getUserForJWT(String(token));
} }
@@ -94,8 +110,23 @@ export default function auth(
}); });
} }
if (options.admin) {
if (!user.isAdmin) {
throw AuthorizationError("Admin role required");
}
}
if (options.member) {
if (user.isViewer) {
throw AuthorizationError("Member role required");
}
}
// not awaiting the promise here so that the request is not blocked // not awaiting the promise here so that the request is not blocked
user.updateActiveAt(ctx.request.ip); user.updateActiveAt(ctx.request.ip).catch((err) => {
Logger.error("Failed to update user activeAt", err);
});
ctx.state.token = String(token); ctx.state.token = String(token);
ctx.state.user = user; ctx.state.user = user;

View File

@@ -276,7 +276,7 @@ class User extends ParanoidModel {
.map((c) => c.id); .map((c) => c.id);
}; };
updateActiveAt = (ip: string, force = false) => { updateActiveAt = async (ip: string, force = false) => {
const fiveMinutesAgo = subMinutes(new Date(), 5); const fiveMinutesAgo = subMinutes(new Date(), 5);
// ensure this is updated only every few minutes otherwise // ensure this is updated only every few minutes otherwise

View File

@@ -8,7 +8,7 @@ import pagination from "./middlewares/pagination";
const router = new Router(); const router = new Router();
router.post("apiKeys.create", auth(), async (ctx) => { router.post("apiKeys.create", auth({ member: true }), async (ctx) => {
const { name } = ctx.body; const { name } = ctx.body;
assertPresent(name, "name is required"); assertPresent(name, "name is required");
const { user } = ctx.state; const { user } = ctx.state;
@@ -35,24 +35,29 @@ router.post("apiKeys.create", auth(), async (ctx) => {
}; };
}); });
router.post("apiKeys.list", auth(), pagination(), async (ctx) => { router.post(
const { user } = ctx.state; "apiKeys.list",
const keys = await ApiKey.findAll({ auth({ member: true }),
where: { pagination(),
userId: user.id, async (ctx) => {
}, const { user } = ctx.state;
order: [["createdAt", "DESC"]], const keys = await ApiKey.findAll({
offset: ctx.state.pagination.offset, where: {
limit: ctx.state.pagination.limit, userId: user.id,
}); },
order: [["createdAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
ctx.body = { ctx.body = {
pagination: ctx.state.pagination, pagination: ctx.state.pagination,
data: keys.map(presentApiKey), data: keys.map(presentApiKey),
}; };
}); }
);
router.post("apiKeys.delete", auth(), async (ctx) => { router.post("apiKeys.delete", auth({ member: true }), async (ctx) => {
const { id } = ctx.body; const { id } = ctx.body;
assertUuid(id, "id is required"); assertUuid(id, "id is required");
const { user } = ctx.state; const { user } = ctx.state;

View File

@@ -15,6 +15,7 @@ import {
buildCollection, buildCollection,
buildUser, buildUser,
buildDocument, buildDocument,
buildViewer,
} from "@server/test/factories"; } from "@server/test/factories";
import { flushdb, seed } from "@server/test/support"; import { flushdb, seed } from "@server/test/support";
@@ -1432,12 +1433,76 @@ describe("#documents.archived", () => {
expect(body.data.length).toEqual(0); expect(body.data.length).toEqual(0);
}); });
it("should require member", async () => {
const viewer = await buildViewer();
const res = await server.post("/api/documents.archived", {
body: {
token: viewer.getJwtToken(),
},
});
expect(res.status).toEqual(403);
});
it("should require authentication", async () => { it("should require authentication", async () => {
const res = await server.post("/api/documents.archived"); const res = await server.post("/api/documents.archived");
expect(res.status).toEqual(401); expect(res.status).toEqual(401);
}); });
}); });
describe("#documents.deleted", () => {
it("should return deleted documents", async () => {
const { user } = await seed();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
await document.delete(user.id);
const res = await server.post("/api/documents.deleted", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
});
it("should not return documents in private collections not a member of", async () => {
const { user } = await seed();
const collection = await buildCollection({
permission: null,
});
const document = await buildDocument({
teamId: user.teamId,
collectionId: collection.id,
});
await document.delete(user.id);
const res = await server.post("/api/documents.deleted", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
it("should require member", async () => {
const viewer = await buildViewer();
const res = await server.post("/api/documents.deleted", {
body: {
token: viewer.getJwtToken(),
},
});
expect(res.status).toEqual(403);
});
it("should require authentication", async () => {
const res = await server.post("/api/documents.deleted");
expect(res.status).toEqual(401);
});
});
describe("#documents.viewed", () => { describe("#documents.viewed", () => {
it("should return empty result if no views", async () => { it("should return empty result if no views", async () => {
const { user } = await seed(); const { user } = await seed();

View File

@@ -162,104 +162,117 @@ router.post("documents.list", auth(), pagination(), async (ctx) => {
}; };
}); });
router.post("documents.archived", auth(), pagination(), async (ctx) => { router.post(
const { sort = "updatedAt" } = ctx.body; "documents.archived",
auth({ member: true }),
pagination(),
async (ctx) => {
const { sort = "updatedAt" } = ctx.body;
assertSort(sort, Document); assertSort(sort, Document);
let direction = ctx.body.direction; let direction = ctx.body.direction;
if (direction !== "ASC") { if (direction !== "ASC") {
direction = "DESC"; direction = "DESC";
}
const { user } = ctx.state;
const collectionIds = await user.collectionIds();
const collectionScope: Readonly<ScopeOptions> = {
method: ["withCollectionPermissions", user.id],
};
const viewScope: Readonly<ScopeOptions> = {
method: ["withViews", user.id],
};
const documents = await Document.scope([
"defaultScope",
collectionScope,
viewScope,
]).findAll({
where: {
teamId: user.teamId,
collectionId: collectionIds,
archivedAt: {
[Op.ne]: null,
},
},
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(
documents.map((document) => presentDocument(document))
);
const policies = presentPolicies(user, documents);
ctx.body = {
pagination: ctx.state.pagination,
data,
policies,
};
} }
const { user } = ctx.state; );
const collectionIds = await user.collectionIds();
const collectionScope: Readonly<ScopeOptions> = { router.post(
method: ["withCollectionPermissions", user.id], "documents.deleted",
}; auth({ member: true }),
const viewScope: Readonly<ScopeOptions> = { pagination(),
method: ["withViews", user.id], async (ctx) => {
}; const { sort = "deletedAt" } = ctx.body;
const documents = await Document.scope([
"defaultScope", assertSort(sort, Document);
collectionScope, let direction = ctx.body.direction;
viewScope, if (direction !== "ASC") {
]).findAll({ direction = "DESC";
where: { }
teamId: user.teamId, const { user } = ctx.state;
collectionId: collectionIds, const collectionIds = await user.collectionIds({
archivedAt: { paranoid: false,
[Op.ne]: null, });
const collectionScope: Readonly<ScopeOptions> = {
method: ["withCollectionPermissions", user.id],
};
const viewScope: Readonly<ScopeOptions> = {
method: ["withViews", user.id],
};
const documents = await Document.scope([
collectionScope,
viewScope,
]).findAll({
where: {
teamId: user.teamId,
collectionId: collectionIds,
deletedAt: {
[Op.ne]: null,
},
}, },
}, include: [
order: [[sort, direction]], {
offset: ctx.state.pagination.offset, model: User,
limit: ctx.state.pagination.limit, as: "createdBy",
}); paranoid: false,
const data = await Promise.all( },
documents.map((document) => presentDocument(document)) {
); model: User,
const policies = presentPolicies(user, documents); as: "updatedBy",
paranoid: false,
},
],
paranoid: false,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(
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, policies,
}; };
});
router.post("documents.deleted", auth(), pagination(), async (ctx) => {
const { sort = "deletedAt" } = ctx.body;
assertSort(sort, Document);
let direction = ctx.body.direction;
if (direction !== "ASC") {
direction = "DESC";
} }
const { user } = ctx.state; );
const collectionIds = await user.collectionIds({
paranoid: false,
});
const collectionScope: Readonly<ScopeOptions> = {
method: ["withCollectionPermissions", user.id],
};
const viewScope: Readonly<ScopeOptions> = {
method: ["withViews", user.id],
};
const documents = await Document.scope([collectionScope, viewScope]).findAll({
where: {
teamId: user.teamId,
collectionId: collectionIds,
deletedAt: {
[Op.ne]: null,
},
},
include: [
{
model: User,
as: "createdBy",
paranoid: false,
},
{
model: User,
as: "updatedBy",
paranoid: false,
},
],
paranoid: false,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(
documents.map((document) => presentDocument(document))
);
const policies = presentPolicies(user, documents);
ctx.body = {
pagination: ctx.state.pagination,
data,
policies,
};
});
router.post("documents.viewed", auth(), pagination(), async (ctx) => { router.post("documents.viewed", auth(), pagination(), async (ctx) => {
let { direction } = ctx.body; let { direction } = ctx.body;
@@ -314,76 +327,81 @@ router.post("documents.viewed", auth(), pagination(), async (ctx) => {
}; };
}); });
router.post("documents.drafts", auth(), pagination(), async (ctx) => { router.post(
let { direction } = ctx.body; "documents.drafts",
const { collectionId, dateFilter, sort = "updatedAt" } = ctx.body; auth({ member: true }),
pagination(),
async (ctx) => {
let { direction } = ctx.body;
const { collectionId, dateFilter, sort = "updatedAt" } = ctx.body;
assertSort(sort, Document); assertSort(sort, Document);
if (direction !== "ASC") { if (direction !== "ASC") {
direction = "DESC"; direction = "DESC";
} }
const { user } = ctx.state; const { user } = ctx.state;
if (collectionId) { if (collectionId) {
assertUuid(collectionId, "collectionId must be a UUID"); assertUuid(collectionId, "collectionId must be a UUID");
const collection = await Collection.scope({ const collection = await Collection.scope({
method: ["withMembership", user.id], method: ["withMembership", user.id],
}).findByPk(collectionId); }).findByPk(collectionId);
authorize(user, "read", collection); authorize(user, "read", collection);
} }
const collectionIds = collectionId const collectionIds = collectionId
? [collectionId] ? [collectionId]
: await user.collectionIds(); : await user.collectionIds();
const where: WhereOptions<Document> = { const where: WhereOptions<Document> = {
createdById: user.id, createdById: user.id,
collectionId: collectionIds, collectionId: collectionIds,
publishedAt: { publishedAt: {
[Op.is]: null, [Op.is]: null,
}, },
}; };
if (dateFilter) { if (dateFilter) {
assertIn( assertIn(
dateFilter, dateFilter,
["day", "week", "month", "year"], ["day", "week", "month", "year"],
"dateFilter must be one of day,week,month,year" "dateFilter must be one of day,week,month,year"
); );
where.updatedAt = { where.updatedAt = {
[Op.gte]: subtractDate(new Date(), dateFilter), [Op.gte]: subtractDate(new Date(), dateFilter),
};
} else {
delete where.updatedAt;
}
const collectionScope: Readonly<ScopeOptions> = {
method: ["withCollectionPermissions", user.id],
};
const documents = await Document.scope([
"defaultScope",
collectionScope,
]).findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(
documents.map((document) => presentDocument(document))
);
const policies = presentPolicies(user, documents);
ctx.body = {
pagination: ctx.state.pagination,
data,
policies,
}; };
} else {
delete where.updatedAt;
} }
);
const collectionScope: Readonly<ScopeOptions> = {
method: ["withCollectionPermissions", user.id],
};
const documents = await Document.scope([
"defaultScope",
collectionScope,
]).findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(
documents.map((document) => presentDocument(document))
);
const policies = presentPolicies(user, documents);
ctx.body = {
pagination: ctx.state.pagination,
data,
policies,
};
});
router.post( router.post(
"documents.info", "documents.info",
auth({ auth({
required: false, optional: true,
}), }),
async (ctx) => { async (ctx) => {
const { id, shareId, apiVersion } = ctx.body; const { id, shareId, apiVersion } = ctx.body;
@@ -421,7 +439,7 @@ router.post(
router.post( router.post(
"documents.export", "documents.export",
auth({ auth({
required: false, optional: true,
}), }),
async (ctx) => { async (ctx) => {
const { id, shareId } = ctx.body; const { id, shareId } = ctx.body;
@@ -438,7 +456,7 @@ router.post(
} }
); );
router.post("documents.restore", auth(), async (ctx) => { router.post("documents.restore", auth({ member: true }), async (ctx) => {
const { id, collectionId, revisionId } = ctx.body; const { id, collectionId, revisionId } = ctx.body;
assertPresent(id, "id is required"); assertPresent(id, "id is required");
const { user } = ctx.state; const { user } = ctx.state;
@@ -588,7 +606,7 @@ router.post("documents.search_titles", auth(), pagination(), async (ctx) => {
router.post( router.post(
"documents.search", "documents.search",
auth({ auth({
required: false, optional: true,
}), }),
pagination(), pagination(),
async (ctx) => { async (ctx) => {
@@ -782,7 +800,7 @@ router.post("documents.unstar", auth(), async (ctx) => {
}; };
}); });
router.post("documents.templatize", auth(), async (ctx) => { router.post("documents.templatize", auth({ member: true }), async (ctx) => {
const { id } = ctx.body; const { id } = ctx.body;
assertPresent(id, "id is required"); assertPresent(id, "id is required");
const { user } = ctx.state; const { user } = ctx.state;
@@ -829,7 +847,7 @@ router.post("documents.templatize", auth(), async (ctx) => {
}; };
}); });
router.post("documents.update", auth(), async (ctx) => { router.post("documents.update", auth({ member: true }), async (ctx) => {
const { const {
id, id,
title, title,
@@ -889,7 +907,7 @@ router.post("documents.update", auth(), async (ctx) => {
}; };
}); });
router.post("documents.move", auth(), async (ctx) => { router.post("documents.move", auth({ member: true }), async (ctx) => {
const { id, collectionId, parentDocumentId, index } = ctx.body; const { id, collectionId, parentDocumentId, index } = ctx.body;
assertUuid(id, "id must be a uuid"); assertUuid(id, "id must be a uuid");
assertUuid(collectionId, "collectionId must be a uuid"); assertUuid(collectionId, "collectionId must be a uuid");
@@ -955,7 +973,7 @@ router.post("documents.move", auth(), async (ctx) => {
}; };
}); });
router.post("documents.archive", auth(), async (ctx) => { router.post("documents.archive", auth({ member: true }), async (ctx) => {
const { id } = ctx.body; const { id } = ctx.body;
assertPresent(id, "id is required"); assertPresent(id, "id is required");
const { user } = ctx.state; const { user } = ctx.state;
@@ -984,7 +1002,7 @@ router.post("documents.archive", auth(), async (ctx) => {
}; };
}); });
router.post("documents.delete", auth(), async (ctx) => { router.post("documents.delete", auth({ member: true }), async (ctx) => {
const { id, permanent } = ctx.body; const { id, permanent } = ctx.body;
assertPresent(id, "id is required"); assertPresent(id, "id is required");
const { user } = ctx.state; const { user } = ctx.state;
@@ -1045,7 +1063,7 @@ router.post("documents.delete", auth(), async (ctx) => {
}; };
}); });
router.post("documents.unpublish", auth(), async (ctx) => { router.post("documents.unpublish", auth({ member: true }), async (ctx) => {
const { id } = ctx.body; const { id } = ctx.body;
assertPresent(id, "id is required"); assertPresent(id, "id is required");
const { user } = ctx.state; const { user } = ctx.state;
@@ -1079,7 +1097,7 @@ router.post("documents.unpublish", auth(), async (ctx) => {
}; };
}); });
router.post("documents.import", auth(), async (ctx) => { router.post("documents.import", auth({ member: true }), async (ctx) => {
const { publish, collectionId, parentDocumentId, index } = ctx.body; const { publish, collectionId, parentDocumentId, index } = ctx.body;
if (!ctx.is("multipart/form-data")) { if (!ctx.is("multipart/form-data")) {
@@ -1162,7 +1180,7 @@ router.post("documents.import", auth(), async (ctx) => {
}); });
}); });
router.post("documents.create", auth(), async (ctx) => { router.post("documents.create", auth({ member: true }), async (ctx) => {
const { const {
title = "", title = "",
text = "", text = "",

View File

@@ -13,7 +13,7 @@ import pagination from "./middlewares/pagination";
const router = new Router(); const router = new Router();
router.post("fileOperations.info", auth(), async (ctx) => { router.post("fileOperations.info", auth({ admin: true }), async (ctx) => {
const { id } = ctx.body; const { id } = ctx.body;
assertUuid(id, "id is required"); assertUuid(id, "id is required");
const { user } = ctx.state; const { user } = ctx.state;
@@ -28,42 +28,47 @@ router.post("fileOperations.info", auth(), async (ctx) => {
}; };
}); });
router.post("fileOperations.list", auth(), pagination(), async (ctx) => { router.post(
let { direction } = ctx.body; "fileOperations.list",
const { sort = "createdAt", type } = ctx.body; auth({ admin: true }),
assertIn(type, Object.values(FileOperationType)); pagination(),
assertSort(sort, FileOperation); async (ctx) => {
let { direction } = ctx.body;
const { sort = "createdAt", type } = ctx.body;
assertIn(type, Object.values(FileOperationType));
assertSort(sort, FileOperation);
if (direction !== "ASC") { if (direction !== "ASC") {
direction = "DESC"; direction = "DESC";
}
const { user } = ctx.state;
const where: WhereOptions<FileOperation> = {
teamId: user.teamId,
type,
};
const team = await Team.findByPk(user.teamId);
authorize(user, "manage", team);
const [exports, total] = await Promise.all([
await FileOperation.findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
await FileOperation.count({
where,
}),
]);
ctx.body = {
pagination: { ...ctx.state.pagination, total },
data: exports.map(presentFileOperation),
};
} }
const { user } = ctx.state; );
const where: WhereOptions<FileOperation> = {
teamId: user.teamId,
type,
};
const team = await Team.findByPk(user.teamId);
authorize(user, "manage", team);
const [exports, total] = await Promise.all([ router.post("fileOperations.redirect", auth({ admin: true }), async (ctx) => {
await FileOperation.findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
await FileOperation.count({
where,
}),
]);
ctx.body = {
pagination: { ...ctx.state.pagination, total },
data: exports.map(presentFileOperation),
};
});
router.post("fileOperations.redirect", auth(), async (ctx) => {
const { id } = ctx.body; const { id } = ctx.body;
assertUuid(id, "id is required"); assertUuid(id, "id is required");
@@ -81,7 +86,7 @@ router.post("fileOperations.redirect", auth(), async (ctx) => {
ctx.redirect(accessUrl); ctx.redirect(accessUrl);
}); });
router.post("fileOperations.delete", auth(), async (ctx) => { router.post("fileOperations.delete", auth({ admin: true }), async (ctx) => {
const { id } = ctx.body; const { id } = ctx.body;
assertUuid(id, "id is required"); assertUuid(id, "id is required");

View File

@@ -11,127 +11,144 @@ import pagination from "./middlewares/pagination";
const router = new Router(); const router = new Router();
router.post("webhookSubscriptions.list", auth(), pagination(), async (ctx) => { router.post(
const { user } = ctx.state; "webhookSubscriptions.list",
authorize(user, "listWebhookSubscription", user.team); auth({ admin: true }),
const webhooks = await WebhookSubscription.findAll({ pagination(),
where: { async (ctx) => {
teamId: user.teamId, const { user } = ctx.state;
}, authorize(user, "listWebhookSubscription", user.team);
order: [["createdAt", "DESC"]], const webhooks = await WebhookSubscription.findAll({
offset: ctx.state.pagination.offset, where: {
limit: ctx.state.pagination.limit, teamId: user.teamId,
}); },
order: [["createdAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
ctx.body = { ctx.body = {
pagination: ctx.state.pagination, pagination: ctx.state.pagination,
data: webhooks.map(presentWebhookSubscription), data: webhooks.map(presentWebhookSubscription),
}; };
});
router.post("webhookSubscriptions.create", auth(), async (ctx) => {
const { user } = ctx.state;
authorize(user, "createWebhookSubscription", user.team);
const { name, url } = ctx.request.body;
const events: string[] = compact(ctx.request.body.events);
assertPresent(name, "name is required");
assertPresent(url, "url is required");
assertArray(events, "events is required");
if (events.length === 0) {
throw ValidationError("events are required");
} }
);
const webhookSubscription = await WebhookSubscription.create({ router.post(
name, "webhookSubscriptions.create",
events, auth({ admin: true }),
createdById: user.id, async (ctx) => {
teamId: user.teamId, const { user } = ctx.state;
url, authorize(user, "createWebhookSubscription", user.team);
enabled: true,
});
const event: WebhookSubscriptionEvent = { const { name, url } = ctx.request.body;
name: "webhook_subscriptions.create", const events: string[] = compact(ctx.request.body.events);
modelId: webhookSubscription.id, assertPresent(name, "name is required");
teamId: user.teamId, assertPresent(url, "url is required");
actorId: user.id, assertArray(events, "events is required");
data: { if (events.length === 0) {
throw ValidationError("events are required");
}
const webhookSubscription = await WebhookSubscription.create({
name, name,
url,
events, events,
}, createdById: user.id,
ip: ctx.request.ip, teamId: user.teamId,
}; url,
await Event.create(event); enabled: true,
});
ctx.body = { const event: WebhookSubscriptionEvent = {
data: presentWebhookSubscription(webhookSubscription), name: "webhook_subscriptions.create",
}; modelId: webhookSubscription.id,
}); teamId: user.teamId,
actorId: user.id,
data: {
name,
url,
events,
},
ip: ctx.request.ip,
};
await Event.create(event);
router.post("webhookSubscriptions.delete", auth(), async (ctx) => { ctx.body = {
const { id } = ctx.body; data: presentWebhookSubscription(webhookSubscription),
assertUuid(id, "id is required"); };
const { user } = ctx.state;
const webhookSubscription = await WebhookSubscription.findByPk(id);
authorize(user, "delete", webhookSubscription);
await webhookSubscription.destroy();
const event: WebhookSubscriptionEvent = {
name: "webhook_subscriptions.delete",
modelId: webhookSubscription.id,
teamId: user.teamId,
actorId: user.id,
data: {
name: webhookSubscription.name,
url: webhookSubscription.url,
events: webhookSubscription.events,
},
ip: ctx.request.ip,
};
await Event.create(event);
});
router.post("webhookSubscriptions.update", auth(), async (ctx) => {
const { id } = ctx.body;
assertUuid(id, "id is required");
const { user } = ctx.state;
const { name, url } = ctx.request.body;
const events: string[] = compact(ctx.request.body.events);
assertPresent(name, "name is required");
assertPresent(url, "url is required");
assertArray(events, "events is required");
if (events.length === 0) {
throw ValidationError("events are required");
} }
);
const webhookSubscription = await WebhookSubscription.findByPk(id); router.post(
"webhookSubscriptions.delete",
auth({ admin: true }),
async (ctx) => {
const { id } = ctx.body;
assertUuid(id, "id is required");
const { user } = ctx.state;
const webhookSubscription = await WebhookSubscription.findByPk(id);
authorize(user, "update", webhookSubscription); authorize(user, "delete", webhookSubscription);
await webhookSubscription.update({ name, url, events, enabled: true }); await webhookSubscription.destroy();
const event: WebhookSubscriptionEvent = { const event: WebhookSubscriptionEvent = {
name: "webhook_subscriptions.update", name: "webhook_subscriptions.delete",
modelId: webhookSubscription.id, modelId: webhookSubscription.id,
teamId: user.teamId, teamId: user.teamId,
actorId: user.id, actorId: user.id,
data: { data: {
name: webhookSubscription.name, name: webhookSubscription.name,
url: webhookSubscription.url, url: webhookSubscription.url,
events: webhookSubscription.events, events: webhookSubscription.events,
}, },
ip: ctx.request.ip, ip: ctx.request.ip,
}; };
await Event.create(event); await Event.create(event);
}
);
ctx.body = { router.post(
data: presentWebhookSubscription(webhookSubscription), "webhookSubscriptions.update",
}; auth({ admin: true }),
}); async (ctx) => {
const { id } = ctx.body;
assertUuid(id, "id is required");
const { user } = ctx.state;
const { name, url } = ctx.request.body;
const events: string[] = compact(ctx.request.body.events);
assertPresent(name, "name is required");
assertPresent(url, "url is required");
assertArray(events, "events is required");
if (events.length === 0) {
throw ValidationError("events are required");
}
const webhookSubscription = await WebhookSubscription.findByPk(id);
authorize(user, "update", webhookSubscription);
await webhookSubscription.update({ name, url, events, enabled: true });
const event: WebhookSubscriptionEvent = {
name: "webhook_subscriptions.update",
modelId: webhookSubscription.id,
teamId: user.teamId,
actorId: user.id,
data: {
name: webhookSubscription.name,
url: webhookSubscription.url,
events: webhookSubscription.events,
},
ip: ctx.request.ip,
};
await Event.create(event);
ctx.body = {
data: presentWebhookSubscription(webhookSubscription),
};
}
);
export default router; export default router;

View File

@@ -117,7 +117,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
router.get( router.get(
"slack.commands", "slack.commands",
auth({ auth({
required: false, optional: true,
}), }),
async (ctx) => { async (ctx) => {
const { code, state, error } = ctx.request.query; const { code, state, error } = ctx.request.query;
@@ -135,9 +135,11 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
if (!user) { if (!user) {
if (state) { if (state) {
try { try {
const team = await Team.findByPk(state as string); const team = await Team.findByPk(String(state), {
rejectOnEmpty: true,
});
return ctx.redirect( return ctx.redirect(
`${team!.url}/auth${ctx.request.path}?${ctx.request.querystring}` `${team.url}/auth${ctx.request.path}?${ctx.request.querystring}`
); );
} catch (err) { } catch (err) {
return ctx.redirect( return ctx.redirect(
@@ -152,8 +154,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
} }
const endpoint = `${env.URL}/auth/slack.commands`; const endpoint = `${env.URL}/auth/slack.commands`;
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | string[] | undefined' i... Remove this comment to see the full error message const data = await Slack.oauthAccess(String(code), endpoint);
const data = await Slack.oauthAccess(code, endpoint);
const authentication = await IntegrationAuthentication.create({ const authentication = await IntegrationAuthentication.create({
service: "slack", service: "slack",
userId: user.id, userId: user.id,
@@ -178,7 +179,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
router.get( router.get(
"slack.post", "slack.post",
auth({ auth({
required: false, optional: true,
}), }),
async (ctx) => { async (ctx) => {
const { code, error, state } = ctx.request.query; const { code, error, state } = ctx.request.query;
@@ -198,10 +199,17 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
// appropriate subdomain to complete the oauth flow // appropriate subdomain to complete the oauth flow
if (!user) { if (!user) {
try { try {
const collection = await Collection.findByPk(state as string); const collection = await Collection.findOne({
const team = await Team.findByPk(collection!.teamId); where: {
id: String(state),
},
rejectOnEmpty: true,
});
const team = await Team.findByPk(collection.teamId, {
rejectOnEmpty: true,
});
return ctx.redirect( return ctx.redirect(
`${team!.url}/auth${ctx.request.path}?${ctx.request.querystring}` `${team.url}/auth${ctx.request.path}?${ctx.request.querystring}`
); );
} catch (err) { } catch (err) {
return ctx.redirect( return ctx.redirect(

View File

@@ -161,6 +161,10 @@ export async function buildAdmin(overrides: Partial<User> = {}) {
return buildUser({ ...overrides, isAdmin: true }); return buildUser({ ...overrides, isAdmin: true });
} }
export async function buildViewer(overrides: Partial<User> = {}) {
return buildUser({ ...overrides, isViewer: true });
}
export async function buildInvite(overrides: Partial<User> = {}) { export async function buildInvite(overrides: Partial<User> = {}) {
if (!overrides.teamId) { if (!overrides.teamId) {
const team = await buildTeam(); const team = await buildTeam();

View File

@@ -1,11 +1,16 @@
import { Context } from "koa"; import { Context } from "koa";
import { FileOperation, Team, User } from "./models"; import { FileOperation, Team, User } from "./models";
export enum AuthenticationTypes {
API = "api",
APP = "app",
}
export type ContextWithState = Context & { export type ContextWithState = Context & {
state: { state: {
user: User; user: User;
token: string; token: string;
authType: "app" | "api"; authType: AuthenticationTypes;
}; };
}; };