refactor authz check

This commit is contained in:
Ramnique Singh 2025-08-05 17:02:52 +05:30
parent 831356a155
commit cd6ff9a46f
10 changed files with 153 additions and 124 deletions

View file

@ -10,6 +10,9 @@ import { FetchCachedTurnUseCase } from "@/src/application/use-cases/conversation
import { CreateCachedTurnController } from "@/src/interface-adapters/controllers/conversations/create-cached-turn.controller"; import { CreateCachedTurnController } from "@/src/interface-adapters/controllers/conversations/create-cached-turn.controller";
import { RunTurnController } from "@/src/interface-adapters/controllers/conversations/run-turn.controller"; import { RunTurnController } from "@/src/interface-adapters/controllers/conversations/run-turn.controller";
import { RedisUsageQuotaPolicy } from "@/src/infrastructure/policies/redis.usage-quota.policy"; import { RedisUsageQuotaPolicy } from "@/src/infrastructure/policies/redis.usage-quota.policy";
import { ProjectActionAuthorizationPolicy } from "@/src/application/policies/project-action-authorization.policy";
import { MongoDBProjectMembersRepository } from "@/src/infrastructure/repositories/mongodb.project-members.repository";
import { MongoDBApiKeysRepository } from "@/src/infrastructure/repositories/mongodb.api-keys.repository";
export const container = createContainer({ export const container = createContainer({
injectionMode: InjectionMode.PROXY, injectionMode: InjectionMode.PROXY,
@ -20,7 +23,19 @@ container.register({
// services // services
// --- // ---
cacheService: asClass(RedisCacheService).singleton(), cacheService: asClass(RedisCacheService).singleton(),
// policies
// ---
usageQuotaPolicy: asClass(RedisUsageQuotaPolicy).singleton(), usageQuotaPolicy: asClass(RedisUsageQuotaPolicy).singleton(),
projectActionAuthorizationPolicy: asClass(ProjectActionAuthorizationPolicy).singleton(),
// project members
// ---
projectMembersRepository: asClass(MongoDBProjectMembersRepository).singleton(),
// api keys
// ---
apiKeysRepository: asClass(MongoDBApiKeysRepository).singleton(),
// conversations // conversations
// --- // ---

View file

@ -0,0 +1,55 @@
import { BadRequestError, NotAuthorizedError } from "@/src/entities/errors/common";
import { IProjectMembersRepository } from "../repositories/project-members.repository.interface";
import { z } from "zod";
import { IApiKeysRepository } from "../repositories/api-keys.repository.interface";
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
});
export interface IProjectActionAuthorizationPolicy {
authorize(data: z.infer<typeof inputSchema>): Promise<void>;
}
export class ProjectActionAuthorizationPolicy implements IProjectActionAuthorizationPolicy {
private readonly projectMembersRepository: IProjectMembersRepository;
private readonly apiKeysRepository: IApiKeysRepository;
constructor({
projectMembersRepository,
apiKeysRepository,
}: {
projectMembersRepository: IProjectMembersRepository;
apiKeysRepository: IApiKeysRepository;
}) {
this.projectMembersRepository = projectMembersRepository;
this.apiKeysRepository = apiKeysRepository;
}
async authorize(data: z.infer<typeof inputSchema>): Promise<void> {
const { caller, userId, apiKey, projectId } = data;
if (caller === "user") {
if (!userId) {
throw new BadRequestError('User ID is required');
}
const membership = await this.projectMembersRepository.checkMembership(projectId, userId);
if (!membership) {
throw new NotAuthorizedError('User is not a member of the project');
}
} else {
if (!apiKey) {
throw new BadRequestError('API key is required');
}
// check and consume api key
// while also updating last used timestamp
const result = await this.apiKeysRepository.checkAndConsumeKey(projectId, apiKey);
if (!result) {
throw new NotAuthorizedError('Invalid API key');
}
}
}
}

View file

@ -0,0 +1,3 @@
export interface IApiKeysRepository {
checkAndConsumeKey(projectId: string, apiKey: string): Promise<boolean>;
}

View file

@ -0,0 +1,3 @@
export interface IProjectMembersRepository {
checkMembership(projectId: string, userId: string): Promise<boolean>;
}

View file

@ -1,11 +1,11 @@
import { BadRequestError, NotAuthorizedError, NotFoundError } from '@/src/entities/errors/common'; import { NotFoundError } from '@/src/entities/errors/common';
import { apiKeysCollection, projectMembersCollection } from "@/app/lib/mongodb";
import { IConversationsRepository } from "@/src/application/repositories/conversations.repository.interface"; import { IConversationsRepository } from "@/src/application/repositories/conversations.repository.interface";
import { z } from "zod"; import { z } from "zod";
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { ICacheService } from '@/src/application/services/cache.service.interface'; import { ICacheService } from '@/src/application/services/cache.service.interface';
import { CachedTurnRequest, Turn } from '@/src/entities/models/turn'; import { CachedTurnRequest, Turn } from '@/src/entities/models/turn';
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface'; import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
const inputSchema = z.object({ const inputSchema = z.object({
caller: z.enum(["user", "api"]), caller: z.enum(["user", "api"]),
@ -23,19 +23,23 @@ export class CreateCachedTurnUseCase implements ICreateCachedTurnUseCase {
private readonly cacheService: ICacheService; private readonly cacheService: ICacheService;
private readonly conversationsRepository: IConversationsRepository; private readonly conversationsRepository: IConversationsRepository;
private readonly usageQuotaPolicy: IUsageQuotaPolicy; private readonly usageQuotaPolicy: IUsageQuotaPolicy;
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
constructor({ constructor({
cacheService, cacheService,
conversationsRepository, conversationsRepository,
usageQuotaPolicy, usageQuotaPolicy,
projectActionAuthorizationPolicy,
}: { }: {
cacheService: ICacheService, cacheService: ICacheService,
conversationsRepository: IConversationsRepository, conversationsRepository: IConversationsRepository,
usageQuotaPolicy: IUsageQuotaPolicy, usageQuotaPolicy: IUsageQuotaPolicy,
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
}) { }) {
this.cacheService = cacheService; this.cacheService = cacheService;
this.conversationsRepository = conversationsRepository; this.conversationsRepository = conversationsRepository;
this.usageQuotaPolicy = usageQuotaPolicy; this.usageQuotaPolicy = usageQuotaPolicy;
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
} }
async execute(data: z.infer<typeof inputSchema>): Promise<{ key: string }> { async execute(data: z.infer<typeof inputSchema>): Promise<{ key: string }> {
@ -51,35 +55,13 @@ export class CreateCachedTurnUseCase implements ICreateCachedTurnUseCase {
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsume(projectId);
// if caller is a user, ensure they are a member of project // authz check
if (data.caller === "user") { await this.projectActionAuthorizationPolicy.authorize({
if (!data.userId) { caller: data.caller,
throw new BadRequestError('User ID is required'); userId: data.userId,
} apiKey: data.apiKey,
const membership = await projectMembersCollection.findOne({ projectId,
projectId, });
userId: data.userId,
});
if (!membership) {
throw new NotAuthorizedError('User not a member of project');
}
} else {
if (!data.apiKey) {
throw new BadRequestError('API key is required');
}
// check if api key is valid
// while also updating last used timestamp
const result = await apiKeysCollection.findOneAndUpdate(
{
projectId,
key: data.apiKey,
},
{ $set: { lastUsedAt: new Date().toISOString() } }
);
if (!result) {
throw new NotAuthorizedError('Invalid API key');
}
}
// create cache entry // create cache entry
const key = nanoid(); const key = nanoid();

View file

@ -1,10 +1,11 @@
import { BadRequestError, NotAuthorizedError, NotFoundError } from '@/src/entities/errors/common'; import { BadRequestError, NotFoundError } from '@/src/entities/errors/common';
import { apiKeysCollection, projectMembersCollection, projectsCollection } from "@/app/lib/mongodb"; import { projectsCollection } from "@/app/lib/mongodb";
import { IConversationsRepository } from "@/src/application/repositories/conversations.repository.interface"; import { IConversationsRepository } from "@/src/application/repositories/conversations.repository.interface";
import { z } from "zod"; import { z } from "zod";
import { Conversation } from "@/src/entities/models/conversation"; import { Conversation } from "@/src/entities/models/conversation";
import { Workflow } from "@/app/lib/types/workflow_types"; import { Workflow } from "@/app/lib/types/workflow_types";
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface'; import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
const inputSchema = z.object({ const inputSchema = z.object({
caller: z.enum(["user", "api"]), caller: z.enum(["user", "api"]),
@ -22,16 +23,20 @@ export interface ICreateConversationUseCase {
export class CreateConversationUseCase implements ICreateConversationUseCase { export class CreateConversationUseCase implements ICreateConversationUseCase {
private readonly conversationsRepository: IConversationsRepository; private readonly conversationsRepository: IConversationsRepository;
private readonly usageQuotaPolicy: IUsageQuotaPolicy; private readonly usageQuotaPolicy: IUsageQuotaPolicy;
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
constructor({ constructor({
conversationsRepository, conversationsRepository,
usageQuotaPolicy, usageQuotaPolicy,
projectActionAuthorizationPolicy,
}: { }: {
conversationsRepository: IConversationsRepository, conversationsRepository: IConversationsRepository,
usageQuotaPolicy: IUsageQuotaPolicy, usageQuotaPolicy: IUsageQuotaPolicy,
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
}) { }) {
this.conversationsRepository = conversationsRepository; this.conversationsRepository = conversationsRepository;
this.usageQuotaPolicy = usageQuotaPolicy; this.usageQuotaPolicy = usageQuotaPolicy;
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
} }
async execute(data: z.infer<typeof inputSchema>): Promise<z.infer<typeof Conversation>> { async execute(data: z.infer<typeof inputSchema>): Promise<z.infer<typeof Conversation>> {
@ -42,35 +47,13 @@ export class CreateConversationUseCase implements ICreateConversationUseCase {
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsume(projectId);
// if caller is a user, ensure they are a member of project // authz check
if (caller === "user") { await this.projectActionAuthorizationPolicy.authorize({
if (!userId) { caller,
throw new BadRequestError('User ID is required'); userId,
} apiKey,
const membership = await projectMembersCollection.findOne({ projectId,
projectId, });
userId,
});
if (!membership) {
throw new NotAuthorizedError('User not a member of project');
}
} else {
if (!apiKey) {
throw new BadRequestError('API key is required');
}
// check if api key is valid
// while also updating last used timestamp
const result = await apiKeysCollection.findOneAndUpdate(
{
projectId,
key: apiKey,
},
{ $set: { lastUsedAt: new Date().toISOString() } }
);
if (!result) {
throw new NotAuthorizedError('Invalid API key');
}
}
// if workflow is not provided, fetch workflow // if workflow is not provided, fetch workflow
if (!workflow) { if (!workflow) {

View file

@ -1,10 +1,10 @@
import { BadRequestError, NotAuthorizedError, NotFoundError } from '@/src/entities/errors/common'; import { NotFoundError } from '@/src/entities/errors/common';
import { apiKeysCollection, projectMembersCollection } from "@/app/lib/mongodb";
import { IConversationsRepository } from "@/src/application/repositories/conversations.repository.interface"; import { IConversationsRepository } from "@/src/application/repositories/conversations.repository.interface";
import { z } from "zod"; import { z } from "zod";
import { ICacheService } from '@/src/application/services/cache.service.interface'; import { ICacheService } from '@/src/application/services/cache.service.interface';
import { CachedTurnRequest, Turn } from '@/src/entities/models/turn'; import { CachedTurnRequest, Turn } from '@/src/entities/models/turn';
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface'; import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
const inputSchema = z.object({ const inputSchema = z.object({
caller: z.enum(["user", "api"]), caller: z.enum(["user", "api"]),
@ -21,19 +21,23 @@ export class FetchCachedTurnUseCase implements IFetchCachedTurnUseCase {
private readonly cacheService: ICacheService; private readonly cacheService: ICacheService;
private readonly conversationsRepository: IConversationsRepository; private readonly conversationsRepository: IConversationsRepository;
private readonly usageQuotaPolicy: IUsageQuotaPolicy; private readonly usageQuotaPolicy: IUsageQuotaPolicy;
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
constructor({ constructor({
cacheService, cacheService,
conversationsRepository, conversationsRepository,
usageQuotaPolicy, usageQuotaPolicy,
projectActionAuthorizationPolicy,
}: { }: {
cacheService: ICacheService, cacheService: ICacheService,
conversationsRepository: IConversationsRepository, conversationsRepository: IConversationsRepository,
usageQuotaPolicy: IUsageQuotaPolicy, usageQuotaPolicy: IUsageQuotaPolicy,
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
}) { }) {
this.cacheService = cacheService; this.cacheService = cacheService;
this.conversationsRepository = conversationsRepository; this.conversationsRepository = conversationsRepository;
this.usageQuotaPolicy = usageQuotaPolicy; this.usageQuotaPolicy = usageQuotaPolicy;
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
} }
async execute(data: z.infer<typeof inputSchema>): Promise<z.infer<typeof CachedTurnRequest>> { async execute(data: z.infer<typeof inputSchema>): Promise<z.infer<typeof CachedTurnRequest>> {
@ -58,35 +62,13 @@ export class FetchCachedTurnUseCase implements IFetchCachedTurnUseCase {
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsume(projectId);
// if caller is a user, ensure they are a member of project // authz check
if (data.caller === "user") { await this.projectActionAuthorizationPolicy.authorize({
if (!data.userId) { caller: data.caller,
throw new BadRequestError('User ID is required'); userId: data.userId,
} apiKey: data.apiKey,
const membership = await projectMembersCollection.findOne({ projectId,
projectId, });
userId: data.userId,
});
if (!membership) {
throw new NotAuthorizedError('User not a member of project');
}
} else {
if (!data.apiKey) {
throw new BadRequestError('API key is required');
}
// check if api key is valid
// while also updating last used timestamp
const result = await apiKeysCollection.findOneAndUpdate(
{
projectId,
key: data.apiKey,
},
{ $set: { lastUsedAt: new Date().toISOString() } }
);
if (!result) {
throw new NotAuthorizedError('Invalid API key');
}
}
// delete from cache // delete from cache
await this.cacheService.delete(`turn-${data.key}`); await this.cacheService.delete(`turn-${data.key}`);

View file

@ -1,13 +1,13 @@
import { Turn, TurnEvent } from "@/src/entities/models/turn"; import { Turn, TurnEvent } from "@/src/entities/models/turn";
import { USE_BILLING } from "@/app/lib/feature_flags"; import { USE_BILLING } from "@/app/lib/feature_flags";
import { authorize, getCustomerIdForProject } from "@/app/lib/billing"; import { authorize, getCustomerIdForProject } from "@/app/lib/billing";
import { BadRequestError, BillingError, NotAuthorizedError, NotFoundError } from '@/src/entities/errors/common'; import { NotFoundError } from '@/src/entities/errors/common';
import { apiKeysCollection, projectMembersCollection } from "@/app/lib/mongodb";
import { IConversationsRepository } from "@/src/application/repositories/conversations.repository.interface"; import { IConversationsRepository } from "@/src/application/repositories/conversations.repository.interface";
import { streamResponse } from "@/app/lib/agents"; import { streamResponse } from "@/app/lib/agents";
import { z } from "zod"; import { z } from "zod";
import { Message } from "@/app/lib/types/types"; import { Message } from "@/app/lib/types/types";
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface'; import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
const inputSchema = z.object({ const inputSchema = z.object({
caller: z.enum(["user", "api"]), caller: z.enum(["user", "api"]),
@ -25,16 +25,20 @@ export interface IRunConversationTurnUseCase {
export class RunConversationTurnUseCase implements IRunConversationTurnUseCase { export class RunConversationTurnUseCase implements IRunConversationTurnUseCase {
private readonly conversationsRepository: IConversationsRepository; private readonly conversationsRepository: IConversationsRepository;
private readonly usageQuotaPolicy: IUsageQuotaPolicy; private readonly usageQuotaPolicy: IUsageQuotaPolicy;
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
constructor({ constructor({
conversationsRepository, conversationsRepository,
usageQuotaPolicy, usageQuotaPolicy,
projectActionAuthorizationPolicy,
}: { }: {
conversationsRepository: IConversationsRepository, conversationsRepository: IConversationsRepository,
usageQuotaPolicy: IUsageQuotaPolicy, usageQuotaPolicy: IUsageQuotaPolicy,
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
}) { }) {
this.conversationsRepository = conversationsRepository; this.conversationsRepository = conversationsRepository;
this.usageQuotaPolicy = usageQuotaPolicy; this.usageQuotaPolicy = usageQuotaPolicy;
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
} }
async *execute(data: z.infer<typeof inputSchema>): AsyncGenerator<z.infer<typeof TurnEvent>, void, unknown> { async *execute(data: z.infer<typeof inputSchema>): AsyncGenerator<z.infer<typeof TurnEvent>, void, unknown> {
@ -50,35 +54,13 @@ export class RunConversationTurnUseCase implements IRunConversationTurnUseCase {
// assert and consume quota // assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId); await this.usageQuotaPolicy.assertAndConsume(projectId);
// if caller is a user, ensure they are a member of project // authz check
if (data.caller === "user") { await this.projectActionAuthorizationPolicy.authorize({
if (!data.userId) { caller: data.caller,
throw new BadRequestError('User ID is required'); userId: data.userId,
} apiKey: data.apiKey,
const membership = await projectMembersCollection.findOne({ projectId,
projectId, });
userId: data.userId,
});
if (!membership) {
throw new NotAuthorizedError('User not a member of project');
}
} else {
if (!data.apiKey) {
throw new BadRequestError('API key is required');
}
// check if api key is valid
// while also updating last used timestamp
const result = await apiKeysCollection.findOneAndUpdate(
{
projectId,
key: data.apiKey,
},
{ $set: { lastUsedAt: new Date().toISOString() } }
);
if (!result) {
throw new NotAuthorizedError('Invalid API key');
}
}
// Check billing auth // Check billing auth
if (USE_BILLING) { if (USE_BILLING) {

View file

@ -0,0 +1,12 @@
import { IApiKeysRepository } from "@/src/application/repositories/api-keys.repository.interface";
import { apiKeysCollection } from "@/app/lib/mongodb";
export class MongoDBApiKeysRepository implements IApiKeysRepository {
async checkAndConsumeKey(projectId: string, apiKey: string): Promise<boolean> {
const result = await apiKeysCollection.findOneAndUpdate(
{ projectId, key: apiKey },
{ $set: { lastUsedAt: new Date().toISOString() } }
);
return !!result;
}
}

View file

@ -0,0 +1,12 @@
import { IProjectMembersRepository } from "@/src/application/repositories/project-members.repository.interface";
import { db } from "@/app/lib/mongodb";
export class MongoDBProjectMembersRepository implements IProjectMembersRepository {
async checkMembership(projectId: string, userId: string): Promise<boolean> {
const membership = await db.collection('project_members').findOne({
projectId,
userId,
});
return !!membership;
}
}