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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user