feat: add domain-based access control for API keys (#198)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Andreas Enemyr
2025-09-10 13:30:37 +02:00
committed by KM Koushik
parent dbc6996d9a
commit 0817b0c7a5
17 changed files with 250 additions and 27 deletions
@@ -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,
+1 -1
View File
@@ -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 } };
};
+2 -2
View File
@@ -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 } };
};
};