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
@@ -9,12 +9,29 @@ export async function addApiKey({
name,
permission,
teamId,
domainId,
}: {
name: string;
permission: ApiPermission;
teamId: number;
domainId?: number;
}) {
try {
// Validate domain ownership if domainId is provided
if (domainId !== undefined) {
const domain = await db.domain.findUnique({
where: {
id: domainId,
teamId: teamId
},
select: { id: true },
});
if (!domain) {
throw new Error("DOMAIN_NOT_FOUND");
}
}
const clientId = smallNanoid(10);
const token = randomBytes(16).toString("hex");
const hashedToken = await createSecureHash(token);
@@ -26,6 +43,7 @@ export async function addApiKey({
name,
permission: permission,
teamId,
domainId,
tokenHash: hashedToken,
partialToken: `${apiKey.slice(0, 6)}...${apiKey.slice(-3)}`,
clientId,
@@ -45,6 +63,11 @@ export async function getTeamAndApiKey(apiKey: string) {
where: {
clientId,
},
include: {
domain: {
select: { id: true, name: true },
},
},
});
if (!apiKeyRow) {
+26 -1
View File
@@ -6,6 +6,7 @@ import { db } from "~/server/db";
import { SesSettingsService } from "./ses-settings-service";
import { UnsendApiError } from "../public-api/api-error";
import { logger } from "../logger/log";
import { ApiKey } from "@prisma/client";
import { LimitService } from "./limit-service";
const dnsResolveTxt = util.promisify(dns.resolveTxt);
@@ -34,7 +35,7 @@ export async function validateDomainFromEmail(email: string, teamId: number) {
});
}
const domain = await db.domain.findUnique({
const domain = await db.domain.findFirst({
where: { name: fromDomain, teamId },
});
@@ -55,6 +56,30 @@ export async function validateDomainFromEmail(email: string, teamId: number) {
return domain;
}
export async function validateApiKeyDomainAccess(
email: string,
teamId: number,
apiKey: ApiKey & { domain?: { name: string } | null }
) {
// First validate the domain exists and is verified
const domain = await validateDomainFromEmail(email, teamId);
// If API key has no domain restriction (domainId is null), allow all domains
if (!apiKey.domainId) {
return domain;
}
// If API key is restricted to a specific domain, check if it matches
if (apiKey.domainId !== domain.id) {
throw new UnsendApiError({
code: "FORBIDDEN",
message: `API key does not have access to domain: ${domain.name}`,
});
}
return domain;
}
export async function createDomain(
teamId: number,
name: string,
+22 -2
View File
@@ -2,7 +2,7 @@ import { EmailContent } from "~/types";
import { db } from "../db";
import { UnsendApiError } from "~/server/public-api/api-error";
import { EmailQueueService } from "./email-queue-service";
import { validateDomainFromEmail } from "./domain-service";
import { validateDomainFromEmail, validateApiKeyDomainAccess } from "./domain-service";
import { EmailRenderer } from "@usesend/email-editor/src/renderer";
import { logger } from "../logger/log";
import { SuppressionService } from "./suppression-service";
@@ -70,7 +70,27 @@ export async function sendEmail(
let subject = subjectFromApiCall;
let html = htmlFromApiCall;
const domain = await validateDomainFromEmail(from, teamId);
let domain: Awaited<ReturnType<typeof validateDomainFromEmail>>;
// If this is an API call with an API key, validate domain access
if (apiKeyId) {
const apiKey = await db.apiKey.findUnique({
where: { id: apiKeyId },
include: { domain: true },
});
if (!apiKey) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: "Invalid API key",
});
}
domain = await validateApiKeyDomainAccess(from, teamId, apiKey);
} else {
// For non-API calls (dashboard, etc.), use regular domain validation
domain = await validateDomainFromEmail(from, teamId);
}
// Check for suppressed emails before sending
const toEmails = Array.isArray(to) ? to : [to];