Skip to content

Commit

Permalink
Authorization (#836)
Browse files Browse the repository at this point in the history
## Description

Add authorization layer to a project.

1. It introduces AppContext type inside `trpc-interface` project. As
this type can be shared across all projects.

2. `createContext(request):AppContext` is placed inside the designer app
it creates context and passes all params known on `remix/server` side
into authorization context.

3. Depending on env variables or SAAS trpc can be used or local so
`authorizationRouter` is a small wrapper around Zanzibar Ory Keto at
SaaS, and implements some subset of ORY operations here.

4. Checks are done on `db` level scripts.

5. On the saas side trpc wrapper around ory is already implemented and
can be checked after this will be merged.

Not everything is checked right now. Should be added later with some
caching at least during single request.

Also not this for this PR.

UI should be different for users with read/edit permissions. Also non
owners can't share links etc



TODO:
- [ ] - Migration - preview token creation for existing projects.

OSS Permission emulates following ORY schema, but without implementing
adding viewers editors etc.

```ts
/**
 * !!! THIS IS NOT typescript code !!!
 * This is Ory permission language (small subset of typescript)
 * https://www.ory.sh/docs/keto/guides/userset-rewrites
 * https://www.ory.sh/docs/keto/reference/ory-permission-language
 *
 * See .prettierrc semicolons are disabled, as it breaks ory language parser (fixed but not in cloud)
 **/

import { Namespace, SubjectSet, Context } from "@ory/keto-namespace-types"

class User implements Namespace {}

class Token implements Namespace {}

class Email implements Namespace {
  related: {
    owner: User[]
  }
}

class Project implements Namespace {
  related: {
    owner: User[]
    editors: (Token | User | SubjectSet<Email, "owner">)[]
    viewers: (Token | User | SubjectSet<Email, "owner">)[]
  }

  /**
   * Relation rewrites:
   * For example if User:AliceUUID is owner of Project:AliceProjectUUID, then
   * we return Allowed for all 3 checks
   * `keto check User:"AliceUUID" own Project "AliceProjectUUID"`
   * `keto check User:"AliceUUID" edit Project "AliceProjectUUID"`
   * `keto check User:"AliceUUID" view Project "AliceProjectUUID"`
   * But if User:"BobUUID" is viewers of Project:AliceProjectUUID, then
   * `keto check User:"BobUUID" view Project "AliceProjectUUID"` returns Allowed
   *
   * `keto check User:"BobUUID" edit Project "AliceProjectUUID"` returns Denied
   * `keto check User:"BobUUID" own Project "AliceProjectUUID"` returns Denied
   **/
  permits = {
    view: (ctx: Context): boolean =>
      this.related.viewers.includes(ctx.subject) ||
      this.related.editors.includes(ctx.subject) ||
      this.related.owner.includes(ctx.subject),

    edit: (ctx: Context): boolean =>
      this.related.editors.includes(ctx.subject) ||
      this.related.owner.includes(ctx.subject),

    own: (ctx: Context): boolean => this.related.owner.includes(ctx.subject),
  }
}
```




## Steps for reproduction

1. click button
6. expect xyz

## Code Review

- [ ] hi @kof, I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)

- [ ] hi @TrySound , I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)

## Before requesting a review

- [x] made a self-review
- [x] added inline comments where things may be not obvious (the "why",
not "what")

## Before merging

- [x] tested locally and on preview environment (preview dev login:
5de6)
- [ ] updated [test
cases](https://github.com/webstudio-is/webstudio-designer/blob/main/apps/designer/docs/test-cases.md)
document
- [ ] added tests
- [ ] if any new env variables are added, added them to `.env.example`
and the `designer/env-check.js` if mandatory
  • Loading branch information
istarkov authored Jan 29, 2023
1 parent 1b3365e commit 6b2c50a
Show file tree
Hide file tree
Showing 42 changed files with 15,752 additions and 7,681 deletions.
5 changes: 5 additions & 0 deletions @types/web-crypto.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
declare module "crypto" {
namespace webcrypto {
const subtle: SubtleCrypto;
}
}
19 changes: 17 additions & 2 deletions apps/designer/app/designer/designer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,11 @@ export type DesignerProps = {
treeId: string;
buildId: string;
buildOrigin: string;
authReadToken: string;
authSharedTokens: {
token: string;
relation: "viewers" | "editors" | "owner";
}[];
};

export const Designer = ({
Expand All @@ -276,14 +281,16 @@ export const Designer = ({
treeId,
buildId,
buildOrigin,
authReadToken,
authSharedTokens,
}: DesignerProps) => {
useSubscribeBreakpoints();
useSetProject(project);
useSetPages(pages);
useSetCurrentPageId(pageId);
const [publish, publishRef] = usePublish();
useDesignerStore(publish);
useSyncServer({ buildId, treeId });
useSyncServer({ buildId, treeId, projectId: project.id });
usePublishAssets(publish);
const [isPreviewMode] = useIsPreviewMode();
usePublishShortcuts(publish);
Expand All @@ -310,13 +317,21 @@ export const Designer = ({
return page;
}, [pages, pageId]);

const canvasUrl = getBuildUrl({ buildOrigin, project, page, mode: "edit" });
const canvasUrl = getBuildUrl({
buildOrigin,
project,
page,
mode: "edit",
authReadToken,
});

const previewUrl = getBuildUrl({
buildOrigin,
project,
page,
mode: "preview",
// Temporary solution until the new share UI is implemented
authToken: authSharedTokens.find((t) => t.relation === "viewers")?.token,
});

return (
Expand Down
11 changes: 7 additions & 4 deletions apps/designer/app/designer/shared/sync-server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { atom } from "nanostores";
import { sync } from "immerhin";
import type { Build } from "@webstudio-is/project";
import type { Build, Project } from "@webstudio-is/project";
import type { Tree } from "@webstudio-is/project-build";
import { restPatchPath } from "~/shared/router-utils";
import { useEffect } from "react";
Expand Down Expand Up @@ -44,9 +44,11 @@ const dequeue = () => {
export const useSyncServer = ({
treeId,
buildId,
projectId,
}: {
buildId: Build["id"];
treeId: Tree["id"];
projectId: Project["id"];
}) => {
useEffect(() => {
const intervalId = setInterval(() => {
Expand All @@ -66,14 +68,15 @@ export const useSyncServer = ({
method: "post",
body: JSON.stringify({
transactions: entries,
treeId: treeId,
buildId: buildId,
treeId,
buildId,
projectId,
}),
})
);
}, 1000);
return () => {
clearInterval(intervalId);
};
}, [treeId, buildId]);
}, [treeId, buildId, projectId]);
};
4 changes: 3 additions & 1 deletion apps/designer/app/routes/$.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { db } from "@webstudio-is/project/server";
import type { DynamicLinksFunction } from "remix-utils";
import type { CanvasData } from "@webstudio-is/project";
import { customComponents } from "~/canvas/custom-components";
import { createContext } from "~/shared/context.server";

type Data = CanvasData & { env: Env; mode: BuildMode };

Expand Down Expand Up @@ -49,14 +50,15 @@ export const meta: MetaFunction = ({ data }: { data: Data }) => {

export const loader = async ({ request }: LoaderArgs): Promise<Data> => {
const buildParams = getBuildParams(request);
const context = await createContext(request);

if (buildParams === undefined) {
throw redirect(dashboardPath());
}

const { mode } = buildParams;

const project = await db.project.loadByParams(buildParams);
const project = await db.project.loadByParams(buildParams, context);

if (project === null) {
throw json("Project not found", { status: 404 });
Expand Down
5 changes: 4 additions & 1 deletion apps/designer/app/routes/dashboard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ComponentProps } from "react";
import { sentryException } from "~/shared/sentry";
import { ErrorMessage } from "~/shared/error";
import { dashboardProjectRouter } from "@webstudio-is/dashboard/server";
import { createContext } from "~/shared/context.server";

export { links } from "~/dashboard";

Expand All @@ -25,9 +26,11 @@ export const loader = async ({
);
}

const context = await createContext(request);

const projects = await dashboardProjectRouter
// @todo pass authorization context
.createCaller({ userId: user.id })
.createCaller(context)
.findMany({ userId: user.id });

return { user, projects };
Expand Down
7 changes: 3 additions & 4 deletions apps/designer/app/routes/dashboard/projects.$method.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import type { ActionArgs } from "@remix-run/node";
import { dashboardProjectRouter } from "@webstudio-is/dashboard/server";
import { findAuthenticatedUser } from "~/services/auth.server";
import { createContext } from "~/shared/context.server";
import { handleTrpcRemixAction } from "~/shared/remix/trpc-remix-request.server";

export const action = async ({ request, params }: ActionArgs) => {
const authenticatedUser = await findAuthenticatedUser(request);
if (authenticatedUser === null) {
throw new Error("Not authenticated");
}
// @todo use createContext
const context = {
userId: authenticatedUser.id,
};

const context = await createContext(request);

return await handleTrpcRemixAction({
request,
Expand Down
26 changes: 25 additions & 1 deletion apps/designer/app/routes/designer/$projectId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { db } from "@webstudio-is/project/server";
import { ErrorMessage } from "~/shared/error";
import { sentryException } from "~/shared/sentry";
import { getBuildOrigin } from "~/shared/router-utils";
import { createContext, createAuthReadToken } from "~/shared/context.server";
import { trpcClient } from "~/services/trpc.server";

export { links };

Expand All @@ -16,10 +18,12 @@ export const loader = async ({
throw new Error("Project id undefined");
}

const context = await createContext(request);

const url = new URL(request.url);
const pageIdParam = url.searchParams.get("pageId");

const project = await db.project.loadById(params.projectId);
const project = await db.project.loadById(params.projectId, context);

if (project === null) {
throw new Error(`Project "${params.projectId}" not found`);
Expand All @@ -31,13 +35,33 @@ export const loader = async ({
const page =
pages.pages.find((page) => page.id === pageIdParam) ?? pages.homePage;

const authReadToken = await createAuthReadToken({ projectId: project.id });

const projectSubjectSets = await trpcClient.authorize.expandLeafNodes.query({
id: project.id,
namespace: "Project",
});

const authSharedTokens: DesignerProps["authSharedTokens"] = [];

for (const subjectSet of projectSubjectSets) {
if (subjectSet.namespace === "Token") {
authSharedTokens.push({
token: subjectSet.id,
relation: subjectSet.relation,
});
}
}

return {
project,
pages,
pageId: pageIdParam || devBuild.pages.homePage.id,
treeId: page.treeId,
buildId: devBuild.id,
buildOrigin: getBuildOrigin(request),
authReadToken,
authSharedTokens,
};
};

Expand Down
25 changes: 19 additions & 6 deletions apps/designer/app/routes/rest/patch.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,32 @@
import type { ActionArgs } from "@remix-run/node";
import type { Build } from "@webstudio-is/project";
import type { Build, Project } from "@webstudio-is/project";
import { db as projectDb } from "@webstudio-is/project/server";
import { type SyncItem } from "immerhin";
import type { Tree } from "@webstudio-is/project-build";
import { createContext } from "~/shared/context.server";

type PatchData = {
transactions: Array<SyncItem>;
treeId: Tree["id"];
buildId: Build["id"];
projectId: Project["id"];
};

export const action = async ({ request }: ActionArgs) => {
const { treeId, buildId, transactions }: PatchData = await request.json();
const { treeId, buildId, projectId, transactions }: PatchData =
await request.json();
if (treeId === undefined) {
return { errors: "Tree id required" };
}
if (buildId === undefined) {
return { errors: "Build id required" };
}
if (projectId === undefined) {
return { errors: "Project id required" };
}

const context = await createContext(request);

// @todo parallelize the updates
// currently not possible because we fetch the entire tree
// and parallelized updates will cause unpredictable side effects
Expand All @@ -26,13 +35,17 @@ export const action = async ({ request }: ActionArgs) => {
const { namespace, patches } = change;

if (namespace === "root") {
await projectDb.tree.patch({ treeId }, patches);
await projectDb.tree.patch({ treeId, projectId }, patches, context);
} else if (namespace === "styles") {
await projectDb.styles.patch({ treeId }, patches);
await projectDb.styles.patch({ treeId, projectId }, patches, context);
} else if (namespace === "props") {
await projectDb.props.patch({ treeId }, patches);
await projectDb.props.patch({ treeId, projectId }, patches, context);
} else if (namespace === "breakpoints") {
await projectDb.breakpoints.patch(buildId, patches);
await projectDb.breakpoints.patch(
{ buildId, projectId },
patches,
context
);
} else {
return { errors: `Unknown namespace "${namespace}"` };
}
Expand Down
7 changes: 5 additions & 2 deletions apps/designer/app/routes/rest/project.$projectId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { db } from "@webstudio-is/project/server";
import { sentryException } from "~/shared/sentry";
import { loadCanvasData } from "~/shared/db";
import type { CanvasData } from "@webstudio-is/project";
import { createContext } from "~/shared/context.server";

type PagesDetails = Array<CanvasData | undefined>;

export const loader = async ({ params }: LoaderArgs) => {
export const loader = async ({ params, request }: LoaderArgs) => {
try {
const projectId = params.projectId ?? undefined;
const pages: PagesDetails = [];
Expand All @@ -16,6 +17,8 @@ export const loader = async ({ params }: LoaderArgs) => {
throw json("Required project id", { status: 400 });
}

const context = await createContext(request);

const prodBuild = await db.build.loadByProjectId(projectId, "prod");
if (prodBuild === undefined) {
throw json(
Expand All @@ -26,7 +29,7 @@ export const loader = async ({ params }: LoaderArgs) => {
const {
pages: { homePage, pages: otherPages },
} = prodBuild;
const project = await db.project.loadByParams({ projectId });
const project = await db.project.loadByParams({ projectId }, context);
if (project === null) {
throw json("Project not found", { status: 404 });
}
Expand Down
34 changes: 20 additions & 14 deletions apps/designer/app/routes/rest/project/clone.$domain.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
import { redirect, type LoaderArgs } from "@remix-run/node";
import type { User } from "@webstudio-is/prisma-client";
import { db as projectDb } from "@webstudio-is/project/server";
import { type Project } from "@webstudio-is/project";
import { findAuthenticatedUser } from "~/services/auth.server";
import { designerPath, loginPath } from "~/shared/router-utils";
import type { AppContext } from "@webstudio-is/trpc-interface/server";
import { createContext } from "~/shared/context.server";

const ensureProject = async ({
userId,
domain,
}: {
userId: User["id"];
domain: string;
}): Promise<Project> => {
const projects = await projectDb.project.loadManyByUserId(userId);
const ensureProject = async (
{
domain,
}: {
domain: string;
},
context: AppContext
): Promise<Project> => {
const projects = await projectDb.project.loadManyByCurrentUserId(context);
if (projects.length !== 0) {
return projects[0];
}

return await projectDb.project.cloneByDomain(domain, userId);
return await projectDb.project.cloneByDomain(domain, context);
};

/**
Expand All @@ -44,11 +46,15 @@ export const loader = async ({ request, params }: LoaderArgs) => {
);
}

const context = await createContext(request);

try {
const project = await ensureProject({
userId: user.id,
domain: params.domain,
});
const project = await ensureProject(
{
domain: params.domain,
},
context
);

return redirect(designerPath({ projectId: project.id }));
} catch (error: unknown) {
Expand Down
6 changes: 5 additions & 1 deletion apps/designer/app/routes/rest/publish.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ActionArgs } from "@remix-run/node";
import { zfd } from "zod-form-data";
import { createContext } from "~/shared/context.server";
import * as db from "~/shared/db";

const schema = zfd.formData({
Expand All @@ -9,8 +10,11 @@ const schema = zfd.formData({

export const action = async ({ request }: ActionArgs) => {
const { domain, projectId } = schema.parse(await request.formData());

try {
await db.misc.publish({ projectId, domain });
const context = await createContext(request);

await db.misc.publish({ projectId, domain }, context);
if (process.env.PUBLISHER_ENDPOINT && process.env.PUBLISHER_TOKEN) {
const headers = new Headers();
headers.append("X-AUTH-WEBSTUDIO", process.env.PUBLISHER_TOKEN || "");
Expand Down
Loading

0 comments on commit 6b2c50a

Please sign in to comment.