diff --git a/README.md b/README.md index 1e8bddc51..34b4b3ba1 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,119 @@ # Redwood Tutorial App -This repo represents the final state of the app created during the [Redwood Tutorial](https://redwoodjs.com/tutorial). -It is meant to be a starting point for those working on the second half of the Tutorial, starting at the [Intermission](https://redwoodjs.com/docs/tutorial/intermission). +This project demonstrates how to use [ZenStack](https://zenstack.dev) in a RedwoodJS project. It's extended based on the blog app used through [RedwoodJS's tutorial](https://redwoodjs.com/docs/tutorial/foreword). -This repo contains much more styling than the one we built together in the tutorial, but is functionally identical. +Please refer to [this blog post](https://zenstack.dev/blog/redwood-auth) for a general introduction. -## Setup +--- -The [tutorial itself](https://redwoodjs.com/docs/tutorial/chapter1/prerequisites) contains instructions for getting this repo up and running, but here is a summary of the commands: +## Steps to get started with ZenStack -```bash -git clone https://github.com/redwoodjs/redwood-tutorial -cd redwood-tutorial -yarn install -yarn rw prisma migrate dev -yarn rw dev -``` +1. Prepare project + + Install CLI + + ```bash + cd api + yarn add -D zenstack + yarn add @zenstackhq/runtime + ``` + + Bootstrap ZModel from `schema.prisma` + + ```bash + cp db/schema.prisma ./schema.zmodel + ``` + +1. Prepare model + + Add the following section to `schema.zmodel` to output the generated Prisma schema to the default location of Redwood: + + ``` + plugin prisma { + provider = '@core/prisma' + output = './db/schema.prisma' + } + ``` + + Run `zenstack generate` and verify that `db/schema.prisma` is updated. + + ```bash + yarn zenstack generate + ``` + +1. Add access policies + + Note the added `@@allow` rules (all operations are denied by default). + + ```prisma + model Post { + id Int @id @default(autoincrement()) + title String + body String + comments Comment[] + user User @relation(fields: [userId], references: [id]) + userId Int + createdAt DateTime @default(now()) + published Boolean @default(true) + + // 🔐 Admin user can do everything to his own posts + @@allow('all', auth().roles == 'admin' && auth() == user) + + // 🔐 Posts are visible to everyone if published + @@allow('read', published) + } + + model Comment { + id Int @id @default(autoincrement()) + name String + body String + post Post @relation(fields: [postId], references: [id]) + postId Int + createdAt DateTime @default(now()) + + // 🔐 Moderator user can do everything to comments + @@allow('all', auth().roles == 'moderator') + + // 🔐 Everyone is allowed to view and create comments for published posts + @@allow('create,read', post.published) + } + + ``` + + See the next section for where the `auth()` function's value comes from. + + Rerun generation and migrate the database. + + ```bash + yarn zenstack generate + yarn rw prisma migrate dev + ``` + +1. Create access-policy-enhanced Prisma Client + + Add the following function to `api/src/lib/db.js`: + + ```js + import { withPolicy } from '@zenstackhq/runtime' + + /* + * Returns ZenStack wrapped Prisma Client with access policies enabled. + */ + export function authDb() { + return withPolicy(db, { user: context.currentUser }) + } + ``` + + It uses the `withPolicy` API to create a Prisma Client wrapper (note the `context.currentUser` is passed in as the current user, which determines what the `auth()` function returns in the ZModel policy rules). + +1. Switch to relying on access policies for authorization + + For example, remove authorization from `api/src/services/comments.js` and use `authDb()` helper to access db. + + ```diff + export const deleteComment = ({ id }) => { + - requireAuth({ roles: 'moderator' }) + - return db.comment.delete({ where: { id } }) + return authDb().comment.delete({ where: { id } }) + } + ``` diff --git a/api/db/migrations/20230620053259_add_policies/migration.sql b/api/db/migrations/20230620053259_add_policies/migration.sql new file mode 100644 index 000000000..f0da32de4 --- /dev/null +++ b/api/db/migrations/20230620053259_add_policies/migration.sql @@ -0,0 +1,49 @@ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" TEXT, + "email" TEXT NOT NULL, + "hashedPassword" TEXT NOT NULL, + "salt" TEXT NOT NULL, + "resetToken" TEXT, + "resetTokenExpiresAt" DATETIME, + "roles" TEXT NOT NULL DEFAULT 'moderator', + "zenstack_guard" BOOLEAN NOT NULL DEFAULT true, + "zenstack_transaction" TEXT +); +INSERT INTO "new_User" ("email", "hashedPassword", "id", "name", "resetToken", "resetTokenExpiresAt", "roles", "salt") SELECT "email", "hashedPassword", "id", "name", "resetToken", "resetTokenExpiresAt", "roles", "salt" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +CREATE INDEX "User_zenstack_transaction_idx" ON "User"("zenstack_transaction"); +CREATE TABLE "new_Comment" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL, + "body" TEXT NOT NULL, + "postId" INTEGER NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "zenstack_guard" BOOLEAN NOT NULL DEFAULT true, + "zenstack_transaction" TEXT, + CONSTRAINT "Comment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_Comment" ("body", "createdAt", "id", "name", "postId") SELECT "body", "createdAt", "id", "name", "postId" FROM "Comment"; +DROP TABLE "Comment"; +ALTER TABLE "new_Comment" RENAME TO "Comment"; +CREATE INDEX "Comment_zenstack_transaction_idx" ON "Comment"("zenstack_transaction"); +CREATE TABLE "new_Post" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "zenstack_guard" BOOLEAN NOT NULL DEFAULT true, + "zenstack_transaction" TEXT, + CONSTRAINT "Post_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_Post" ("body", "createdAt", "id", "title", "userId") SELECT "body", "createdAt", "id", "title", "userId" FROM "Post"; +DROP TABLE "Post"; +ALTER TABLE "new_Post" RENAME TO "Post"; +CREATE INDEX "Post_zenstack_transaction_idx" ON "Post"("zenstack_transaction"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/api/db/migrations/20230622084334_add_published/migration.sql b/api/db/migrations/20230622084334_add_published/migration.sql new file mode 100644 index 000000000..5f6d98d82 --- /dev/null +++ b/api/db/migrations/20230622084334_add_published/migration.sql @@ -0,0 +1,19 @@ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Post" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "published" BOOLEAN NOT NULL DEFAULT true, + "zenstack_guard" BOOLEAN NOT NULL DEFAULT true, + "zenstack_transaction" TEXT, + CONSTRAINT "Post_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_Post" ("body", "createdAt", "id", "title", "userId", "zenstack_guard", "zenstack_transaction") SELECT "body", "createdAt", "id", "title", "userId", "zenstack_guard", "zenstack_transaction" FROM "Post"; +DROP TABLE "Post"; +ALTER TABLE "new_Post" RENAME TO "Post"; +CREATE INDEX "Post_zenstack_transaction_idx" ON "Post"("zenstack_transaction"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/api/db/schema.prisma b/api/db/schema.prisma index 154619e09..0e176f067 100644 --- a/api/db/schema.prisma +++ b/api/db/schema.prisma @@ -1,48 +1,74 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + datasource db { - provider = "sqlite" - url = env("DATABASE_URL") + provider="sqlite" + url=env("DATABASE_URL") } generator client { - provider = "prisma-client-js" - binaryTargets = "native" + provider = "prisma-client-js" + binaryTargets = "native" + previewFeatures = ["interactiveTransactions"] } +/// @@allow('all', auth().roles == 'admin' && auth() == user) +/// @@allow('read', published) model Post { - id Int @id @default(autoincrement()) - title String - body String - comments Comment[] - user User @relation(fields: [userId], references: [id]) - userId Int - createdAt DateTime @default(now()) + id Int @id() @default(autoincrement()) + title String + body String + comments Comment[] + user User @relation(fields: [userId], references: [id]) + userId Int + createdAt DateTime @default(now()) + published Boolean @default(true) + + zenstack_guard Boolean @default(true) + zenstack_transaction String? + + @@index([zenstack_transaction]) } model Contact { - id Int @id @default(autoincrement()) - name String - email String - message String - createdAt DateTime @default(now()) + id Int @id() @default(autoincrement()) + name String + email String + message String + createdAt DateTime @default(now()) } model User { - id Int @id @default(autoincrement()) - name String? - email String @unique - hashedPassword String - salt String - resetToken String? - resetTokenExpiresAt DateTime? - roles String @default("moderator") - posts Post[] + id Int @id() @default(autoincrement()) + name String? + email String @unique() + hashedPassword String + salt String + resetToken String? + resetTokenExpiresAt DateTime? + roles String @default("moderator") + posts Post[] + + zenstack_guard Boolean @default(true) + zenstack_transaction String? + + @@index([zenstack_transaction]) } +/// @@allow('all', auth().roles == 'moderator') +/// @@allow('create,read', post.published) model Comment { - id Int @id @default(autoincrement()) - name String - body String - post Post @relation(fields: [postId], references: [id]) - postId Int - createdAt DateTime @default(now()) -} + id Int @id() @default(autoincrement()) + name String + body String + post Post @relation(fields: [postId], references: [id]) + postId Int + createdAt DateTime @default(now()) + + zenstack_guard Boolean @default(true) + zenstack_transaction String? + + @@index([zenstack_transaction]) +} \ No newline at end of file diff --git a/api/package.json b/api/package.json index 2edfead03..76b0f4613 100644 --- a/api/package.json +++ b/api/package.json @@ -4,6 +4,10 @@ "private": true, "dependencies": { "@redwoodjs/api": "3.2.0", - "@redwoodjs/graphql-server": "3.2.0" + "@redwoodjs/graphql-server": "3.2.0", + "@zenstackhq/runtime": "^1.0.0-beta.2" + }, + "devDependencies": { + "zenstack": "^1.0.0-beta.2" } } diff --git a/api/schema.zmodel b/api/schema.zmodel new file mode 100644 index 000000000..d8959267c --- /dev/null +++ b/api/schema.zmodel @@ -0,0 +1,67 @@ +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +plugin prisma { + provider = '@core/prisma' + output = './db/schema.prisma' +} + +generator client { + provider = "prisma-client-js" + binaryTargets = "native" + previewFeatures = ["interactiveTransactions"] +} + +model Post { + id Int @id @default(autoincrement()) + title String + body String + comments Comment[] + user User @relation(fields: [userId], references: [id]) + userId Int + createdAt DateTime @default(now()) + published Boolean @default(true) + + // 🔐 Admin user can do everything to his own posts + @@allow('all', auth().roles == 'admin' && auth() == user) + + // 🔐 Posts are visible to everyone if published + @@allow('read', published) +} + +model Contact { + id Int @id @default(autoincrement()) + name String + email String + message String + createdAt DateTime @default(now()) +} + +model User { + id Int @id @default(autoincrement()) + name String? + email String @unique + hashedPassword String + salt String + resetToken String? + resetTokenExpiresAt DateTime? + roles String @default("moderator") + posts Post[] +} + +model Comment { + id Int @id @default(autoincrement()) + name String + body String + post Post @relation(fields: [postId], references: [id]) + postId Int + createdAt DateTime @default(now()) + + // 🔐 Moderator user can do everything to comments + @@allow('all', auth().roles == 'moderator') + + // 🔐 Everyone is allowed to view and create comments for published posts + @@allow('create,read', post.published) +} diff --git a/api/src/graphql/adminPosts.sdl.js b/api/src/graphql/adminPosts.sdl.js index c4b49a6e4..0a49626e2 100644 --- a/api/src/graphql/adminPosts.sdl.js +++ b/api/src/graphql/adminPosts.sdl.js @@ -18,6 +18,8 @@ export const schema = gql` createPost(input: CreatePostInput!): Post! @requireAuth(roles: ["admin"]) updatePost(id: Int!, input: UpdatePostInput!): Post! @requireAuth(roles: ["admin"]) + publishPost(id: Int!): Post! @requireAuth(roles: ["admin"]) + unpublishPost(id: Int!): Post! @requireAuth(roles: ["admin"]) deletePost(id: Int!): Post! @requireAuth(roles: ["admin"]) } ` diff --git a/api/src/graphql/comments.sdl.js b/api/src/graphql/comments.sdl.js index 2dd153d03..156c64cd5 100644 --- a/api/src/graphql/comments.sdl.js +++ b/api/src/graphql/comments.sdl.js @@ -26,6 +26,6 @@ export const schema = gql` type Mutation { createComment(input: CreateCommentInput!): Comment! @skipAuth - deleteComment(id: Int!): Comment! @requireAuth(roles: "moderator") + deleteComment(id: Int!): Comment! @skipAuth } ` diff --git a/api/src/graphql/posts.sdl.js b/api/src/graphql/posts.sdl.js index 95b30ddf4..ae78e0f82 100644 --- a/api/src/graphql/posts.sdl.js +++ b/api/src/graphql/posts.sdl.js @@ -5,6 +5,7 @@ export const schema = gql` body: String! createdAt: DateTime! user: User! + published: Boolean! } type Query { diff --git a/api/src/lib/db.js b/api/src/lib/db.js index 5006d00aa..993a14913 100644 --- a/api/src/lib/db.js +++ b/api/src/lib/db.js @@ -2,6 +2,7 @@ // for options. import { PrismaClient } from '@prisma/client' +import { withPolicy } from '@zenstackhq/runtime' import { emitLogLevels, handlePrismaLogging } from '@redwoodjs/api/logger' @@ -14,6 +15,14 @@ export const db = new PrismaClient({ log: emitLogLevels(['info', 'warn', 'error']), }) +/* + * Returns ZenStack wrapped Prisma Client with access policies enabled. + */ +export function authDb() { + console.log('Context User:', context.currentUser) + return withPolicy(db, { user: context.currentUser }) +} + handlePrismaLogging({ db, logger, diff --git a/api/src/services/adminPosts/adminPosts.js b/api/src/services/adminPosts/adminPosts.js index d409905e9..897ef4605 100644 --- a/api/src/services/adminPosts/adminPosts.js +++ b/api/src/services/adminPosts/adminPosts.js @@ -35,6 +35,28 @@ export const updatePost = async ({ id, input }) => { }) } +export const publishPost = async ({ id }) => { + await validateOwnership({ id }) + + return db.post.update({ + where: { id }, + data: { + published: true, + }, + }) +} + +export const unpublishPost = async ({ id }) => { + await validateOwnership({ id }) + + return db.post.update({ + where: { id }, + data: { + published: false, + }, + }) +} + export const deletePost = async ({ id }) => { await validateOwnership({ id }) diff --git a/api/src/services/comments/comments.js b/api/src/services/comments/comments.js index 7eface794..42345d118 100644 --- a/api/src/services/comments/comments.js +++ b/api/src/services/comments/comments.js @@ -1,31 +1,29 @@ -import { requireAuth } from 'src/lib/auth' -import { db } from 'src/lib/db' +import { authDb } from 'src/lib/db' export const comments = ({ postId }) => { - return db.comment.findMany({ where: { postId } }) + return authDb().comment.findMany({ where: { postId } }) } export const comment = ({ id }) => { - return db.comment.findUnique({ + return authDb().comment.findUnique({ where: { id }, }) } export const createComment = ({ input }) => { - return db.comment.create({ + return authDb().comment.create({ data: input, }) } export const deleteComment = ({ id }) => { - requireAuth({ roles: 'moderator' }) - return db.comment.delete({ + return authDb().comment.delete({ where: { id }, }) } export const Comment = { post: (_obj, { root }) => { - return db.comment.findUnique({ where: { id: root?.id } }).post() + return authDb().post.findUnique({ where: { id: root?.postId } }) }, } diff --git a/api/src/services/posts/posts.js b/api/src/services/posts/posts.js index b41b1d610..d7a880fd7 100644 --- a/api/src/services/posts/posts.js +++ b/api/src/services/posts/posts.js @@ -1,16 +1,15 @@ -import { db } from 'src/lib/db' +import { authDb, db } from 'src/lib/db' export const posts = (...args) => { - return db.post.findMany() + return authDb().post.findMany() } export const post = ({ id }) => { - return db.post.findUnique({ + return authDb().post.findUnique({ where: { id }, }) } export const Post = { - user: (_obj, { root }) => - db.post.findFirst({ where: { id: root.id } }).user(), + user: (_obj, { root }) => db.user.findUnique({ where: { id: root.userId } }), } diff --git a/web/src/components/Comment/Comment.js b/web/src/components/Comment/Comment.js index d6ae550e8..d737b1d13 100644 --- a/web/src/components/Comment/Comment.js +++ b/web/src/components/Comment/Comment.js @@ -43,15 +43,15 @@ const Comment = ({ comment }) => {
{comment.body}
- {hasRole('moderator') && ( - - )} + {/* {hasRole('moderator') && ( */} + + {/* )} */} ) } diff --git a/web/src/components/Post/Posts/Posts.js b/web/src/components/Post/Posts/Posts.js index 4c44edfaa..7ea34a27a 100644 --- a/web/src/components/Post/Posts/Posts.js +++ b/web/src/components/Post/Posts/Posts.js @@ -1,6 +1,6 @@ +import { Link, routes } from '@redwoodjs/router' import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' -import { Link, routes } from '@redwoodjs/router' import { QUERY } from 'src/components/Post/PostsCell' @@ -12,6 +12,22 @@ const DELETE_POST_MUTATION = gql` } ` +const PUBLISH_POST_MUTATION = gql` + mutation PublishPostMutation($id: Int!) { + publishPost(id: $id) { + id + } + } +` + +const UNPUBLISH_POST_MUTATION = gql` + mutation UnpublishPostMutation($id: Int!) { + unpublishPost(id: $id) { + id + } + } +` + const MAX_STRING_LENGTH = 150 const truncate = (text) => { @@ -50,6 +66,36 @@ const PostsList = ({ posts }) => { awaitRefetchQueries: true, }) + const [publishPost] = useMutation(PUBLISH_POST_MUTATION, { + onCompleted: () => { + toast.success('Post published') + }, + // This refetches the query on the list page. Read more about other ways to + // update the cache over here: + // https://www.apollographql.com/docs/react/data/mutations/#making-all-other-cache-updates + refetchQueries: [{ query: QUERY }], + awaitRefetchQueries: true, + }) + + const [unpublishPost] = useMutation(UNPUBLISH_POST_MUTATION, { + onCompleted: () => { + toast.success('Post unpublished') + }, + // This refetches the query on the list page. Read more about other ways to + // update the cache over here: + // https://www.apollographql.com/docs/react/data/mutations/#making-all-other-cache-updates + refetchQueries: [{ query: QUERY }], + awaitRefetchQueries: true, + }) + + const onPublishClick = (id) => { + publishPost({ variables: { id } }) + } + + const onUnpublishClick = (id) => { + unpublishPost({ variables: { id } }) + } + const onDeleteClick = (id) => { if (confirm('Are you sure you want to delete post ' + id + '?')) { deletePost({ variables: { id } }) @@ -91,6 +137,26 @@ const PostsList = ({ posts }) => { > Edit + {post.published && ( + onUnpublishClick(post.id)} + > + Unpublish + + )} + {!post.published && ( + onPublishClick(post.id)} + > + Publish + + )} ": + version: 1.22.2 + resolution: "resolve@patch:resolve@npm%3A1.22.2#~builtin