diff --git a/packages/auth/package.json b/packages/auth/package.json index fabf9c0..a8677e6 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -30,7 +30,7 @@ "bcryptjs": "^2.4.3", "graphql": "^16.12.0", "reflect-metadata": "^0.2.2", - "zod": "^3.25.76" + "zod": "^4.3.6" }, "devDependencies": { "@biomejs/biome": "^2.2.6", diff --git a/packages/auth/src/authorization/index.ts b/packages/auth/src/authorization/index.ts index 41543f3..cba2193 100644 --- a/packages/auth/src/authorization/index.ts +++ b/packages/auth/src/authorization/index.ts @@ -4,5 +4,6 @@ export * from "./owner-owned-resource.policy"; export { Policy } from "./policy.decorator"; export type { Policy as IPolicy } from "./policy.interface"; export * from "./policy-registry.service"; +export * from "./profile-owned-resource.policy"; export * from "./public-resource.policy"; export * from "./user-owned-resource.policy"; diff --git a/packages/auth/src/authorization/profile-owned-resource.policy.ts b/packages/auth/src/authorization/profile-owned-resource.policy.ts new file mode 100644 index 0000000..e49c39a --- /dev/null +++ b/packages/auth/src/authorization/profile-owned-resource.policy.ts @@ -0,0 +1,40 @@ +import { PrismaService } from "@cv/system"; +import type { User } from "../user/user.entity"; +import type { Policy } from "./policy.interface"; + +export abstract class ProfileOwnedResourcePolicy< + TResource extends { profileId: string }, +> implements Policy +{ + constructor(protected readonly prisma: PrismaService) {} + + view(user: User, resource: TResource): Promise { + return this.isOwner(user, resource); + } + + async create( + user: User, + resource?: Partial, + ): Promise { + if (!resource) return true; + if (!("profileId" in resource) || typeof resource.profileId !== "string") + return false; + return this.isOwner(user, resource as TResource); + } + + update(user: User, resource: TResource): Promise { + return this.isOwner(user, resource); + } + + delete(user: User, resource: TResource): Promise { + return this.isOwner(user, resource); + } + + private async isOwner(user: User, resource: TResource): Promise { + const profile = await this.prisma.profile.findUnique({ + where: { id: resource.profileId }, + select: { userId: true }, + }); + return profile?.userId === user.id; + } +} diff --git a/packages/auth/src/guards/admin.guard.ts b/packages/auth/src/guards/admin.guard.ts new file mode 100644 index 0000000..498674a --- /dev/null +++ b/packages/auth/src/guards/admin.guard.ts @@ -0,0 +1,27 @@ +import { + type CanActivate, + type ExecutionContext, + ForbiddenException, + Injectable, +} from "@nestjs/common"; +import { GqlExecutionContext } from "@nestjs/graphql"; +import type { User } from "../user/user.entity"; + +/** + * Guard that restricts access to admin users. + * Must be stacked after JwtAuthGuard (which populates request.user). + */ +@Injectable() +export class AdminGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const ctx = GqlExecutionContext.create(context); + const request = ctx.getContext().req; + const user = request.user as User | undefined; + + if (!user?.isAdmin) { + throw new ForbiddenException("Admin access required"); + } + + return true; + } +} diff --git a/packages/auth/src/guards/index.ts b/packages/auth/src/guards/index.ts index fb9a64f..4ce53e4 100644 --- a/packages/auth/src/guards/index.ts +++ b/packages/auth/src/guards/index.ts @@ -1,2 +1,3 @@ +export * from "./admin.guard"; export * from "./jwt-auth.guard"; export * from "./verified-scope.guard"; diff --git a/packages/auth/src/identity-provider-registry.service.ts b/packages/auth/src/identity-provider-registry.service.ts index 043b2bf..e435aaa 100644 --- a/packages/auth/src/identity-provider-registry.service.ts +++ b/packages/auth/src/identity-provider-registry.service.ts @@ -1,7 +1,7 @@ import { raise } from "@cv/system"; import { Injectable, type OnModuleInit } from "@nestjs/common"; import { DiscoveryService, Reflector } from "@nestjs/core"; -import { z } from "zod"; +import { z } from "zod/v4"; import { IDENTITY_PROVIDER_KEY, type IdentityProviderMeta, @@ -14,17 +14,13 @@ type ProviderEntry = { }; const identityProviderMetaSchema = z.object({ - name: z.custom((val) => typeof val === "symbol", { - message: "name must be a symbol", - }), + name: z.custom((val) => typeof val === "symbol"), priority: z.number().optional(), }); -const identityProviderInstanceSchema = z - .object({ - authenticate: z.function(), - }) - .passthrough(); +const identityProviderInstanceSchema = z.looseObject({ + authenticate: z.unknown(), +}); @Injectable() export class IdentityProviderRegistry implements OnModuleInit { @@ -86,7 +82,7 @@ export class IdentityProviderRegistry implements OnModuleInit { return result; } catch (error) { if (error instanceof z.ZodError) { - const errorMessages = error.errors + const errorMessages = error.issues .map(({ path, message }) => `${path.join(".")}: ${message}`) .join(", "); throw new Error( @@ -108,7 +104,7 @@ export class IdentityProviderRegistry implements OnModuleInit { return instance as IdentityProvider; } catch (error) { if (error instanceof z.ZodError) { - const errorMessages = error.errors + const errorMessages = error.issues .map((e) => `${e.path.join(".")}: ${e.message}`) .join(", "); throw new Error( diff --git a/packages/auth/src/user/user.entity.ts b/packages/auth/src/user/user.entity.ts index e2e45e6..8b4a60d 100644 --- a/packages/auth/src/user/user.entity.ts +++ b/packages/auth/src/user/user.entity.ts @@ -1,6 +1,11 @@ import { BaseEntity } from "@cv/system"; import type { Credentials } from "./credentials.entity"; +export enum UserRole { + USER = "USER", + ADMIN = "ADMIN", +} + export class User extends BaseEntity { constructor( id: string, @@ -8,7 +13,12 @@ export class User extends BaseEntity { createdAt: Date, updatedAt: Date, public credentials: Credentials | null = null, + public role: UserRole = UserRole.USER, ) { super(id, createdAt, updatedAt); } + + get isAdmin(): boolean { + return this.role === UserRole.ADMIN; + } } diff --git a/packages/auth/src/user/user.mapper.ts b/packages/auth/src/user/user.mapper.ts index 875eaec..3615dd2 100644 --- a/packages/auth/src/user/user.mapper.ts +++ b/packages/auth/src/user/user.mapper.ts @@ -2,7 +2,7 @@ import type { BaseMapper } from "@cv/system"; import { Injectable } from "@nestjs/common"; import type { Prisma } from "@prisma/client"; import { CredentialsMapper } from "./credentials.mapper"; -import { User } from "./user.entity"; +import { User, UserRole } from "./user.entity"; type PrismaUserWithCredentials = Prisma.UserGetPayload<{ include: { credentials: true }; @@ -28,6 +28,7 @@ export class UserMapper implements BaseMapper { prismaUser.createdAt, prismaUser.updatedAt, credentials, + prismaUser.role as UserRole, ); }