-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Move mutation authorization from GraphQL-level to service-level #793
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| --- | ||
| '@baseplate-dev/project-builder-lib': patch | ||
| '@baseplate-dev/project-builder-server': patch | ||
| '@baseplate-dev/project-builder-web': patch | ||
| '@baseplate-dev/fastify-generators': patch | ||
| --- | ||
|
|
||
| Move mutation authorization from GraphQL-level to service-level, add compact grid-based role picker UI, filter system role from auth pickers, and hide disabled service methods from GraphQL mutations section |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import { prisma } from '@src/services/prisma.js'; | ||
| import { createModelAuthorizer } from '@src/utils/authorizers.js'; | ||
|
|
||
| export const blogAuthorizer = createModelAuthorizer({ | ||
| model: 'blog', | ||
| idField: 'id', | ||
| getModelById: (id) => prisma.blog.findUnique({ where: { id } }), | ||
| roles: { owner: (ctx, model) => model.userId === ctx.auth.userId }, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import { queryFromInfo } from '@pothos/plugin-prisma'; | ||
|
|
||
| import { builder } from '@src/plugins/graphql/builder.js'; | ||
|
|
||
| import { | ||
| blogUpdateSchema, | ||
| deleteBlog, | ||
| updateBlog, | ||
| } from '../services/blog.data-service.js'; | ||
| import { blogObjectType } from './blog.object-type.js'; | ||
|
|
||
| const updateBlogDataInputType = builder | ||
| .inputType('UpdateBlogData', { | ||
| fields: (t) => ({ name: t.string(), userId: t.field({ type: 'Uuid' }) }), | ||
| }) | ||
| .validate(blogUpdateSchema); | ||
|
|
||
| builder.mutationField('updateBlog', (t) => | ||
| t.fieldWithInputPayload({ | ||
| input: { | ||
| id: t.input.field({ required: true, type: 'Uuid' }), | ||
| data: t.input.field({ required: true, type: updateBlogDataInputType }), | ||
| }, | ||
| payload: { blog: t.payload.field({ type: blogObjectType }) }, | ||
| authorize: ['public', 'user', 'system', 'admin'], | ||
| resolve: async (root, { input: { id, data } }, context, info) => { | ||
| const blog = await updateBlog({ | ||
| where: { id }, | ||
| data, | ||
| context, | ||
| query: queryFromInfo({ context, info, path: ['blog'] }), | ||
| }); | ||
| return { blog }; | ||
| }, | ||
| }), | ||
| ); | ||
|
|
||
| builder.mutationField('deleteBlog', (t) => | ||
| t.fieldWithInputPayload({ | ||
| input: { id: t.input.field({ required: true, type: 'Uuid' }) }, | ||
| payload: { blog: t.payload.field({ type: blogObjectType }) }, | ||
| authorize: ['public', 'user', 'system', 'admin'], | ||
| resolve: async (root, { input: { id } }, context, info) => { | ||
| const blog = await deleteBlog({ | ||
| where: { id }, | ||
| context, | ||
| query: queryFromInfo({ context, info, path: ['blog'] }), | ||
| }); | ||
| return { blog }; | ||
| }, | ||
| }), | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| import { z } from 'zod'; | ||
|
|
||
| import type { | ||
| GetPayload, | ||
| ModelQuery, | ||
| } from '@src/utils/data-operations/prisma-types.js'; | ||
| import type { | ||
| DataDeleteInput, | ||
| DataUpdateInput, | ||
| } from '@src/utils/data-operations/types.js'; | ||
|
|
||
| import { prisma } from '@src/services/prisma.js'; | ||
| import { | ||
| commitDelete, | ||
| commitUpdate, | ||
| } from '@src/utils/data-operations/commit-operations.js'; | ||
| import { composeUpdate } from '@src/utils/data-operations/compose-operations.js'; | ||
| import { scalarField } from '@src/utils/data-operations/field-definitions.js'; | ||
| import { generateUpdateSchema } from '@src/utils/data-operations/field-utils.js'; | ||
| import { relationHelpers } from '@src/utils/data-operations/relation-helpers.js'; | ||
|
|
||
| import { blogAuthorizer } from '../authorizers/blog.authorizer.js'; | ||
|
|
||
| export const blogInputFields = { | ||
| name: scalarField(z.string()), | ||
| userId: scalarField(z.uuid()), | ||
| }; | ||
|
|
||
| export const blogUpdateSchema = generateUpdateSchema(blogInputFields); | ||
|
|
||
| export async function updateBlog< | ||
| TQueryArgs extends ModelQuery<'blog'> = ModelQuery<'blog'>, | ||
| >({ | ||
| where, | ||
| data: input, | ||
| query, | ||
| context, | ||
| }: DataUpdateInput<'blog', typeof blogInputFields, TQueryArgs>): Promise< | ||
| GetPayload<'blog', TQueryArgs> | ||
| > { | ||
| const plan = await composeUpdate({ | ||
| model: 'blog', | ||
| fields: blogInputFields, | ||
| input, | ||
| context, | ||
| loadExisting: () => prisma.blog.findUniqueOrThrow({ where }), | ||
| authorize: ['admin', blogAuthorizer.roles.owner], | ||
| }); | ||
|
|
||
| return commitUpdate(plan, { | ||
| query, | ||
| execute: async ({ tx, data: { userId, ...rest }, query }) => { | ||
| const item = await tx.blog.update({ | ||
| where, | ||
| data: { ...rest, user: relationHelpers.connectUpdate({ id: userId }) }, | ||
| ...query, | ||
| }); | ||
| return item; | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| export async function deleteBlog< | ||
| TQueryArgs extends ModelQuery<'blog'> = ModelQuery<'blog'>, | ||
| >({ | ||
| where, | ||
| query, | ||
| context, | ||
| }: DataDeleteInput<'blog', TQueryArgs>): Promise< | ||
| GetPayload<'blog', TQueryArgs> | ||
| > { | ||
| return commitDelete({ | ||
| model: 'blog', | ||
| query, | ||
| context, | ||
| execute: async ({ tx, query }) => { | ||
| const item = await tx.blog.delete({ | ||
| where, | ||
| ...query, | ||
| }); | ||
| return item; | ||
| }, | ||
| authorize: ['admin', blogAuthorizer.roles.owner], | ||
| loadExisting: () => prisma.blog.findUniqueOrThrow({ where }), | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import { prisma } from '@src/services/prisma.js'; | ||
| import { createModelAuthorizer } from '@src/utils/authorizers.js'; | ||
|
|
||
| export const blogAuthorizer = createModelAuthorizer({ | ||
| model: 'blog', | ||
| idField: 'id', | ||
| getModelById: (id) => prisma.blog.findUnique({ where: { id } }), | ||
| roles: { owner: (ctx, model) => model.userId === ctx.auth.userId }, | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import { queryFromInfo } from '@pothos/plugin-prisma'; | ||
|
|
||
| import { builder } from '@src/plugins/graphql/builder.js'; | ||
|
|
||
| import { | ||
| blogUpdateSchema, | ||
| deleteBlog, | ||
| updateBlog, | ||
| } from '../services/blog.data-service.js'; | ||
| import { blogObjectType } from './blog.object-type.js'; | ||
|
|
||
| const updateBlogDataInputType = builder | ||
| .inputType('UpdateBlogData', { | ||
| fields: (t) => ({ name: t.string(), userId: t.field({ type: 'Uuid' }) }), | ||
| }) | ||
| .validate(blogUpdateSchema); | ||
|
|
||
| builder.mutationField('updateBlog', (t) => | ||
| t.fieldWithInputPayload({ | ||
| input: { | ||
| id: t.input.field({ required: true, type: 'Uuid' }), | ||
| data: t.input.field({ required: true, type: updateBlogDataInputType }), | ||
| }, | ||
| payload: { blog: t.payload.field({ type: blogObjectType }) }, | ||
| authorize: ['public', 'user', 'system', 'admin'], | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cat -n examples/blog-with-auth/apps/backend/src/modules/blogs/schema/blog.mutations.tsRepository: halfdomelabs/baseplate Length of output: 2032 🏁 Script executed: rg -n "authorize:" --type=ts examples/blog-with-auth/apps/backend -C 2 | head -80Repository: halfdomelabs/baseplate Length of output: 7456 🏁 Script executed: # Check if there are query/subscription files to compare authorization patterns
fd -e ts examples/blog-with-auth/apps/backend/src/modules --name "*.queries.ts" --o --name "*.subscriptions.ts" | xargs -I {} sh -c 'echo "=== {} ===" && rg -n "authorize:" {} -C 1'Repository: halfdomelabs/baseplate Length of output: 316 Remove The mutations declare 🤖 Prompt for AI Agents |
||
| resolve: async (root, { input: { id, data } }, context, info) => { | ||
| const blog = await updateBlog({ | ||
| where: { id }, | ||
| data, | ||
| context, | ||
| query: queryFromInfo({ context, info, path: ['blog'] }), | ||
| }); | ||
| return { blog }; | ||
| }, | ||
| }), | ||
| ); | ||
|
|
||
| builder.mutationField('deleteBlog', (t) => | ||
| t.fieldWithInputPayload({ | ||
| input: { id: t.input.field({ required: true, type: 'Uuid' }) }, | ||
| payload: { blog: t.payload.field({ type: blogObjectType }) }, | ||
| authorize: ['public', 'user', 'system', 'admin'], | ||
| resolve: async (root, { input: { id } }, context, info) => { | ||
| const blog = await deleteBlog({ | ||
| where: { id }, | ||
| context, | ||
| query: queryFromInfo({ context, info, path: ['blog'] }), | ||
| }); | ||
| return { blog }; | ||
| }, | ||
| }), | ||
| ); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Guard owner-role evaluation against missing auth context.
Line 8 directly reads
ctx.auth.userId; if auth context is absent, authorization can throw instead of denying access.Proposed fix
🤖 Prompt for AI Agents