Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/service-method-auth.md
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
Expand Up @@ -43,6 +43,7 @@
"@baseplate-dev/fastify-generators#core/request-service-context:request-service-context": "src/utils/request-service-context.ts",
"@baseplate-dev/fastify-generators#core/service-context:service-context": "src/utils/service-context.ts",
"@baseplate-dev/fastify-generators#core/service-context:test-helper": "src/tests/helpers/service-context.test-helper.ts",
"@baseplate-dev/fastify-generators#core/service-file:prisma-data-service:Blog": "src/modules/blogs/services/blog.data-service.ts",
"@baseplate-dev/fastify-generators#core/service-file:prisma-data-service:User": "src/modules/accounts/services/user.data-service.ts",
"@baseplate-dev/fastify-generators#pothos/pothos-auth:field-authorize-global-types": "src/plugins/graphql/FieldAuthorizePlugin/global-types.ts",
"@baseplate-dev/fastify-generators#pothos/pothos-auth:field-authorize-plugin": "src/plugins/graphql/FieldAuthorizePlugin/index.ts",
Expand All @@ -55,6 +56,7 @@
"@baseplate-dev/fastify-generators#pothos/pothos-scalar:uuid": "src/modules/graphql/scalars/uuid.ts",
"@baseplate-dev/fastify-generators#pothos/pothos-sentry:use-sentry": "src/plugins/graphql/use-sentry.ts",
"@baseplate-dev/fastify-generators#pothos/pothos-types-file:model:3BNvsyfylc9c-object-type": "src/modules/accounts/schema/user-role.object-type.ts",
"@baseplate-dev/fastify-generators#pothos/pothos-types-file:model:h7nbXcuLcUmy-mutations": "src/modules/blogs/schema/blog.mutations.ts",
"@baseplate-dev/fastify-generators#pothos/pothos-types-file:model:h7nbXcuLcUmy-object-type": "src/modules/blogs/schema/blog.object-type.ts",
"@baseplate-dev/fastify-generators#pothos/pothos-types-file:model:h7nbXcuLcUmy-queries": "src/modules/blogs/schema/blog.queries.ts",
"@baseplate-dev/fastify-generators#pothos/pothos-types-file:model:jZxZ8T0aLW5C-mutations": "src/modules/accounts/schema/user.mutations.ts",
Expand All @@ -75,6 +77,7 @@
"@baseplate-dev/fastify-generators#prisma/data-utils:relation-helpers": "src/utils/data-operations/relation-helpers.ts",
"@baseplate-dev/fastify-generators#prisma/data-utils:types": "src/utils/data-operations/types.ts",
"@baseplate-dev/fastify-generators#prisma/prisma-authorizer-utils:utils-authorizers": "src/utils/authorizers.ts",
"@baseplate-dev/fastify-generators#prisma/prisma-model-authorizer:prisma-model-authorizer:Blog": "src/modules/blogs/authorizers/blog.authorizer.ts",
"@baseplate-dev/fastify-generators#prisma/prisma-model-authorizer:prisma-model-authorizer:User": "src/modules/accounts/authorizers/user.authorizer.ts",
"@baseplate-dev/fastify-generators#prisma/prisma:client": "src/generated/prisma/client.ts",
"@baseplate-dev/fastify-generators#prisma/prisma:prisma-config": "prisma.config.ts",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export async function createUser<
fields: userInputFields,
input,
context,
authorize: ['admin'],
});

return commitCreate(plan, {
Expand Down Expand Up @@ -80,6 +81,7 @@ export async function updateUser<
input,
context,
loadExisting: () => prisma.user.findUniqueOrThrow({ where }),
authorize: ['admin'],
});

return commitUpdate(plan, {
Expand Down Expand Up @@ -115,5 +117,6 @@ export async function deleteUser<
});
return item;
},
authorize: ['admin'],
});
}
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
@@ -1,6 +1,7 @@
import { flattenAppModule } from '@src/utils/app-modules.js';

/* TPL_IMPORTS:START */
import './schema/blog.mutations.js';
import './schema/blog.object-type.js';
import './schema/blog.queries.js';
/* TPL_IMPORTS:END */
Expand Down
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 }),
});
}
4 changes: 2 additions & 2 deletions examples/blog-with-auth/apps/backend/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,12 @@ type Mutation {

type Query {
blog(id: Uuid!): Blog!
blogs: [Blog!]!
blogs(skip: Int, take: Int): [Blog!]!

"""Get the current user session"""
currentUserSession: UserSessionPayload
user(id: Uuid!): User!
users: [User!]!
users(skip: Int, take: Int): [User!]!

"""The currently authenticated user"""
viewer: User
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export async function createUser<
fields: userInputFields,
input,
context,
authorize: ['admin'],
});

return commitCreate(plan, {
Expand Down Expand Up @@ -80,6 +81,7 @@ export async function updateUser<
input,
context,
loadExisting: () => prisma.user.findUniqueOrThrow({ where }),
authorize: ['admin'],
});

return commitUpdate(plan, {
Expand Down Expand Up @@ -115,5 +117,6 @@ export async function deleteUser<
});
return item;
},
authorize: ['admin'],
});
}
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 },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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
-  roles: { owner: (ctx, model) => model.userId === ctx.auth.userId },
+  roles: {
+    owner: (ctx, model): boolean =>
+      ctx.auth?.userId != null && model.userId === ctx.auth.userId,
+  },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@examples/blog-with-auth/apps/backend/src/modules/blogs/authorizers/blog.authorizer.ts`
at line 8, The owner role lambda in blog.authorizer.ts (roles: { owner: (ctx,
model) => model.userId === ctx.auth.userId }) can throw when ctx.auth is
missing; update the owner evaluator to first guard that ctx.auth and
ctx.auth.userId exist (e.g., check ctx.auth && ctx.auth.userId or
ctx.auth?.userId) and return false if missing, then compare model.userId to the
userId only when present so authorization safely denies instead of throwing.

});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { flattenAppModule } from '@src/utils/app-modules.js';

/* TPL_IMPORTS:START */
import './schema/blog.mutations.js';
import './schema/blog.object-type.js';
import './schema/blog.queries.js';
/* TPL_IMPORTS:END */
Expand Down
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'],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n examples/blog-with-auth/apps/backend/src/modules/blogs/schema/blog.mutations.ts

Repository: halfdomelabs/baseplate

Length of output: 2032


🏁 Script executed:

rg -n "authorize:" --type=ts examples/blog-with-auth/apps/backend -C 2 | head -80

Repository: 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 'public' from the authorize array.

The mutations declare ['public', 'user', 'system', 'admin'], but the underlying service methods (updateBlog and deleteBlog in blog.data-service.ts) enforce ['admin', blogAuthorizer.roles.owner]. This inconsistency means the schema-level authorization is misleading. Change authorize to match the service layer: ['admin', blogAuthorizer.roles.owner] for both updateBlog and deleteBlog mutations (or use the restrictive authorization from the service methods that are actually called in the resolve functions).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@examples/blog-with-auth/apps/backend/src/modules/blogs/schema/blog.mutations.ts`
at line 25, The schema-level authorize array for the updateBlog and deleteBlog
mutations is too permissive (includes 'public'); update the authorize entries in
blog.mutations.ts for the updateBlog and deleteBlog fields to match the
service-level enforcement by replacing ['public','user','system','admin'] with
the restrictive set used by the data service: ['admin',
blogAuthorizer.roles.owner] so schema and the updateBlog/deleteBlog resolve
paths align.

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 };
},
}),
);
Loading