feat: add domain-based access control for API keys (#198)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
committed by
KM Koushik
parent
dbc6996d9a
commit
0817b0c7a5
@@ -33,3 +33,26 @@ export const checkIsValidEmailId = async (emailId: string, teamId: number) => {
|
||||
throw new UnsendApiError({ code: "NOT_FOUND", message: "Email not found" });
|
||||
}
|
||||
};
|
||||
|
||||
export const checkIsValidEmailIdWithDomainRestriction = async (
|
||||
emailId: string,
|
||||
teamId: number,
|
||||
apiKeyDomainId?: number
|
||||
) => {
|
||||
const whereClause: { id: string; teamId: number; domainId?: number } = {
|
||||
id: emailId,
|
||||
teamId,
|
||||
};
|
||||
|
||||
if (apiKeyDomainId !== undefined) {
|
||||
whereClause.domainId = apiKeyDomainId;
|
||||
}
|
||||
|
||||
const email = await db.email.findUnique({ where: whereClause });
|
||||
|
||||
if (!email) {
|
||||
throw new UnsendApiError({ code: "NOT_FOUND", message: "Email not found" });
|
||||
}
|
||||
|
||||
return email;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { DomainSchema } from "~/lib/zod/domain-schema";
|
||||
import { PublicAPIApp } from "~/server/public-api/hono";
|
||||
import { db } from "~/server/db";
|
||||
import { getTeamFromToken } from "~/server/public-api/auth";
|
||||
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
@@ -14,7 +13,7 @@ const route = createRoute({
|
||||
schema: z.array(DomainSchema),
|
||||
},
|
||||
},
|
||||
description: "Retrieve the user",
|
||||
description: "Retrieve domains accessible by the API key",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -23,7 +22,12 @@ function getDomains(app: PublicAPIApp) {
|
||||
app.openapi(route, async (c) => {
|
||||
const team = c.var.team;
|
||||
|
||||
const domains = await db.domain.findMany({ where: { teamId: team.id } });
|
||||
// If API key is restricted to a specific domain, only return that domain; else return all team domains
|
||||
const domains = team.apiKey.domainId
|
||||
? await db.domain.findMany({
|
||||
where: { teamId: team.id, id: team.apiKey.domainId },
|
||||
})
|
||||
: await db.domain.findMany({ where: { teamId: team.id } });
|
||||
|
||||
return c.json(domains);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { PublicAPIApp } from "~/server/public-api/hono";
|
||||
import { getTeamFromToken } from "~/server/public-api/auth";
|
||||
import { db } from "~/server/db";
|
||||
|
||||
const route = createRoute({
|
||||
@@ -26,15 +25,70 @@ const route = createRoute({
|
||||
}),
|
||||
},
|
||||
},
|
||||
description: "Create a new domain",
|
||||
description: "Verify domain",
|
||||
},
|
||||
403: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
error: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
description: "Forbidden - API key doesn't have access to this domain",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
error: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
description: "Domain not found",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function verifyDomain(app: PublicAPIApp) {
|
||||
app.openapi(route, async (c) => {
|
||||
const team = c.var.team;
|
||||
const domainId = c.req.valid("param").id;
|
||||
|
||||
// Check if API key has access to this domain
|
||||
let domain = null;
|
||||
|
||||
if (team.apiKey.domainId) {
|
||||
// If API key is restricted to a specific domain, verify the requested domain matches
|
||||
if (domainId === team.apiKey.domainId) {
|
||||
domain = await db.domain.findFirst({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
id: domainId
|
||||
},
|
||||
});
|
||||
}
|
||||
// If domainId doesn't match the API key's restriction, domain remains null
|
||||
} else {
|
||||
// API key has access to all team domains
|
||||
domain = await db.domain.findFirst({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
id: domainId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!domain) {
|
||||
return c.json({
|
||||
error: team.apiKey.domainId
|
||||
? "API key doesn't have access to this domain"
|
||||
: "Domain not found"
|
||||
}, 404);
|
||||
}
|
||||
|
||||
await db.domain.update({
|
||||
where: { id: c.req.valid("param").id },
|
||||
where: { id: domainId },
|
||||
data: { isVerifying: true },
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { PublicAPIApp } from "~/server/public-api/hono";
|
||||
import { getTeamFromToken } from "~/server/public-api/auth";
|
||||
import { cancelEmail } from "~/server/service/email-service";
|
||||
import { checkIsValidEmailId } from "../../api-utils";
|
||||
import { checkIsValidEmailIdWithDomainRestriction } from "../../api-utils";
|
||||
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
@@ -37,7 +37,7 @@ function cancelScheduledEmail(app: PublicAPIApp) {
|
||||
app.openapi(route, async (c) => {
|
||||
const team = c.var.team;
|
||||
const emailId = c.req.param("emailId");
|
||||
await checkIsValidEmailId(emailId, team.id);
|
||||
await checkIsValidEmailIdWithDomainRestriction(emailId, team.id, team.apiKey.domainId);
|
||||
|
||||
await cancelEmail(emailId);
|
||||
|
||||
|
||||
@@ -58,14 +58,19 @@ const route = createRoute({
|
||||
function send(app: PublicAPIApp) {
|
||||
app.openapi(route, async (c) => {
|
||||
const team = c.var.team;
|
||||
|
||||
const emailId = c.req.param("emailId");
|
||||
|
||||
const whereClause: { id: string; teamId: number; domainId?: number } = {
|
||||
id: emailId,
|
||||
teamId: team.id,
|
||||
};
|
||||
|
||||
if (team.apiKey.domainId !== null) {
|
||||
whereClause.domainId = team.apiKey.domainId;
|
||||
}
|
||||
|
||||
const email = await db.email.findUnique({
|
||||
where: {
|
||||
id: emailId,
|
||||
teamId: team.id,
|
||||
},
|
||||
where: whereClause,
|
||||
select: {
|
||||
id: true,
|
||||
teamId: true,
|
||||
|
||||
@@ -123,7 +123,9 @@ function listEmails(app: PublicAPIApp) {
|
||||
};
|
||||
}
|
||||
|
||||
if (domainId && domainId.length > 0) {
|
||||
if (team.apiKey.domainId !== null) {
|
||||
whereClause.domainId = team.apiKey.domainId;
|
||||
} else if (domainId && domainId.length > 0) {
|
||||
whereClause.domainId = { in: domainId };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { PublicAPIApp } from "~/server/public-api/hono";
|
||||
import { getTeamFromToken } from "~/server/public-api/auth";
|
||||
import { updateEmail } from "~/server/service/email-service";
|
||||
import { checkIsValidEmailId } from "../../api-utils";
|
||||
import { checkIsValidEmailIdWithDomainRestriction } from "../../api-utils";
|
||||
|
||||
const route = createRoute({
|
||||
method: "patch",
|
||||
@@ -48,7 +48,7 @@ function updateEmailScheduledAt(app: PublicAPIApp) {
|
||||
const team = c.var.team;
|
||||
const emailId = c.req.param("emailId");
|
||||
|
||||
await checkIsValidEmailId(emailId, team.id);
|
||||
await checkIsValidEmailIdWithDomainRestriction(emailId, team.id, team.apiKey.domainId);
|
||||
|
||||
await updateEmail(emailId, {
|
||||
scheduledAt: c.req.valid("json").scheduledAt,
|
||||
|
||||
@@ -59,5 +59,5 @@ export const getTeamFromToken = async (c: Context) => {
|
||||
logger.error({ err }, "Failed to update lastUsed on API key")
|
||||
);
|
||||
|
||||
return { ...team, apiKeyId: apiKey.id };
|
||||
return { ...team, apiKeyId: apiKey.id, apiKey: { domainId: apiKey.domainId } };
|
||||
};
|
||||
|
||||
@@ -7,13 +7,13 @@ import { getRedis } from "~/server/redis";
|
||||
import { getTeamFromToken } from "~/server/public-api/auth";
|
||||
import { isSelfHosted } from "~/utils/common";
|
||||
import { UnsendApiError } from "./api-error";
|
||||
import { Team } from "@prisma/client";
|
||||
import { Team, ApiKey } from "@prisma/client";
|
||||
import { logger } from "../logger/log";
|
||||
|
||||
// Define AppEnv for Hono context
|
||||
export type AppEnv = {
|
||||
Variables: {
|
||||
team: Team & { apiKeyId: number };
|
||||
team: Team & { apiKeyId: number; apiKey: { domainId: number | null } };
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user