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
9 changes: 9 additions & 0 deletions .changeset/refactor-authorization-abac.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@baseplate-dev/fastify-generators': patch
---

Refactor authorization system to use ABAC pattern with string-based roles

- Add `authorizerCache` and `authorizerModelCache` fields to ServiceContext via the `authorizer-utils` generator for caching authorization decisions
- Remove `AuthRole` type and `extractRoles` config from pothos-auth generator as authorization now uses string-based roles with instance role functions
- Add new authorizer utilities including `checkGlobalAuthorization`, `checkInstanceAuthorization`, and `createModelAuthorizer` for flexible authorization patterns
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"@baseplate-dev/fastify-generators#prisma/data-utils:prisma-utils": "src/utils/data-operations/prisma-utils.ts",
"@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:client": "src/generated/prisma/client.ts",
"@baseplate-dev/fastify-generators#prisma/prisma:prisma-config": "prisma.config.ts",
"@baseplate-dev/fastify-generators#prisma/prisma:prisma-schema": "prisma/schema.prisma",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type {
FieldNullability,
InputFieldMap,
InputShapeFromFields,
SchemaTypes,
TypeParam,
} from '@pothos/core';
Expand All @@ -21,18 +20,8 @@ declare global {
authorizeByRoles: PothosAuthorizeByRolesPlugin<Types>;
}

export interface UserSchemaTypes {
AuthRole: string;
}

export interface ExtendDefaultTypes<
PartialTypes extends Partial<UserSchemaTypes>,
> {
AuthRole: PartialTypes['AuthRole'] & string;
}

export interface SchemaBuilderOptions<Types extends SchemaTypes> {
authorizeByRoles: AuthorizeRolePluginOptions<Types>;
authorizeByRoles: AuthorizeRolePluginOptions;
}

export interface FieldOptions<
Expand All @@ -44,11 +33,7 @@ declare global {
ResolveShape,
ResolveReturnShape,
> {
authorize?: AuthorizeRoleRuleOption<
ParentShape,
InputShapeFromFields<Args>,
Types
>;
authorize?: AuthorizeRoleRuleOption<ParentShape>;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import type { PothosOutputFieldConfig, SchemaTypes } from '@pothos/core';
import type { GraphQLFieldResolver, GraphQLResolveInfo } from 'graphql';
import type { GraphQLFieldResolver } from 'graphql';

import SchemaBuilder, { BasePlugin } from '@pothos/core';

import { ForbiddenError } from '@src/utils/http-errors.js';
import type { ServiceContext } from '@src/utils/service-context.js';

import type {
AuthorizeRoleRuleFunction,
AuthorizeRoleRuleOption,
} from './types.js';
import { checkInstanceAuthorization } from '@src/utils/authorizers.js';

import type { AuthorizeRoleRuleOption } from './types.js';

import './global-types.js';

Expand Down Expand Up @@ -37,59 +36,14 @@ export class PothosAuthorizeByRolesPlugin<

async authorizeAccess(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
authorize: AuthorizeRoleRuleOption<any, any, Types>,
authorize: AuthorizeRoleRuleOption<any>,
root: unknown,
args: object,
context: Types['Context'],
info: GraphQLResolveInfo,
): Promise<void> {
const rules = Array.isArray(authorize) ? authorize : [authorize];
const roles = this.builder.options.authorizeByRoles.extractRoles(context);

// process all string rules first
const stringRules = rules.filter(
(rule): rule is Types['AuthRole'] => typeof rule === 'string',
);

if (stringRules.some((rule) => roles.includes(rule))) {
return;
}

const ruleFunctions = rules.filter(
(rule): rule is AuthorizeRoleRuleFunction<unknown, unknown, Types> =>
typeof rule === 'function',
);

// try all rules and see if any match
const results = await Promise.allSettled(
ruleFunctions.map((func) => func(root, args, context, info)),
);

// if any check passed, return success
if (results.some((r) => r.status === 'fulfilled' && r.value)) {
return;
}

// if a check threw an unexpected error, throw that since it may mean
// the authorization rule may have been valid but failed to run
const unexpectedError = results.find(
(r) => r.status === 'rejected' && !(r.reason instanceof ForbiddenError),
) as PromiseRejectedResult | undefined;

if (unexpectedError) {
throw unexpectedError.reason;
}

// if a check threw a forbidden error with a message, throw that
const forbiddenError = results.find(
(r) => r.status === 'rejected' && r.reason instanceof ForbiddenError,
) as PromiseRejectedResult | undefined;

if (forbiddenError) {
throw forbiddenError.reason;
}
const ctx = context as ServiceContext;

throw new ForbiddenError('Forbidden');
await checkInstanceAuthorization(ctx, root, rules);
}

override wrapResolve(
Expand All @@ -102,7 +56,7 @@ export class PothosAuthorizeByRolesPlugin<
}

return async (source, args, context, info) => {
await this.authorizeAccess(authorize, source, args, context, info);
await this.authorizeAccess(authorize, source, context);
return resolver(source, args, context, info);
};
}
Expand All @@ -117,7 +71,7 @@ export class PothosAuthorizeByRolesPlugin<
}

return async (source, args, context, info) => {
await this.authorizeAccess(authorize, source, args, context, info);
await this.authorizeAccess(authorize, source, context);
return subscriber(source, args, context, info);
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,30 +1,33 @@
import type { SchemaTypes } from '@pothos/core';
import type { GraphQLResolveInfo } from 'graphql';
import type {
GlobalRoleCheck,
InstanceRoleCheck,
} from '@src/utils/authorizers.js';

export type AuthorizeRoleRuleFunction<
RootType,
ArgsType,
Types extends SchemaTypes,
> = (
root: RootType,
args: ArgsType,
context: Types['Context'],
info: GraphQLResolveInfo,
) => boolean | Promise<boolean>;
/**
* Single authorization rule - either a string (global role) or function (instance role).
* Discrimination: `typeof rule === 'string'` for global roles.
*
* @example
* ```typescript
* // Global role (string)
* const adminRule: AuthorizeRoleRule<any> = 'admin';
*
* // Instance role (function)
* const ownerRule: AuthorizeRoleRule<User> = (ctx, root) => root.id === ctx.auth.userId;
* ```
*/
export type AuthorizeRoleRule<RootType> =
| GlobalRoleCheck
| InstanceRoleCheck<RootType>;

export type AuthorizeRoleRule<RootType, ArgsType, Types extends SchemaTypes> =
| Types['AuthRole']
| AuthorizeRoleRuleFunction<RootType, ArgsType, Types>;
/**
* Authorization option - can be a single rule or array of rules.
* If multiple rules are provided, access is granted if ANY rule returns true.
*/
export type AuthorizeRoleRuleOption<RootType> =
| AuthorizeRoleRule<RootType>
| AuthorizeRoleRule<RootType>[];

export type AuthorizeRoleRuleOption<
RootType,
ArgsType,
Types extends SchemaTypes,
> =
| AuthorizeRoleRule<RootType, ArgsType, Types>
| AuthorizeRoleRule<RootType, ArgsType, Types>[];

export interface AuthorizeRolePluginOptions<Types extends SchemaTypes> {
export interface AuthorizeRolePluginOptions {
requireOnRootFields?: boolean;
extractRoles: (context: Types['Context']) => readonly Types['AuthRole'][];
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import ValidationPlugin from '@pothos/plugin-validation';
import { createSentryWrapper } from '@pothos/tracing-sentry';

import type PrismaTypes from '@src/generated/prisma/pothos-prisma-types.js';
import type { AuthRole } from '@src/modules/accounts/constants/auth-roles.constants.js';
import type { RequestServiceContext } from '@src/utils/request-service-context.js';

import { getDatamodel } from '@src/generated/prisma/pothos-prisma-types.js';
Expand All @@ -27,7 +26,6 @@ const traceResolver = createSentryWrapper({

export const builder = new SchemaBuilder<{
/* TPL_SCHEMA_TYPE_OPTIONS:START */
AuthRole: AuthRole;
Context: RequestServiceContext;
DefaultEdgesNullability: false;
DefaultFieldNullability: false;
Expand All @@ -45,10 +43,7 @@ export const builder = new SchemaBuilder<{
/* TPL_SCHEMA_TYPE_OPTIONS:END */
}>(
/* TPL_SCHEMA_BUILDER_OPTIONS:START */ {
authorizeByRoles: {
extractRoles: (context) => context.auth.roles,
requireOnRootFields: true,
},
authorizeByRoles: { requireOnRootFields: true },
defaultFieldNullability: false,
plugins: [
PrismaPlugin,
Expand Down
Loading