add site provisioning keys to integration api

This commit is contained in:
miloschwartz
2026-07-16 17:56:31 -04:00
parent a88b79e066
commit 3c9f0946d2
10 changed files with 355 additions and 23 deletions
+4
View File
@@ -1511,6 +1511,10 @@
"actionListInvitations": "List Invitations", "actionListInvitations": "List Invitations",
"actionExportLogs": "Export Logs", "actionExportLogs": "Export Logs",
"actionViewLogs": "View Logs", "actionViewLogs": "View Logs",
"actionCreateSiteProvisioningKey": "Create Site Provisioning Key",
"actionListSiteProvisioningKeys": "List Site Provisioning Keys",
"actionUpdateSiteProvisioningKey": "Update Site Provisioning Key",
"actionDeleteSiteProvisioningKey": "Delete Site Provisioning Key",
"noneSelected": "None selected", "noneSelected": "None selected",
"orgNotFound2": "No organizations found.", "orgNotFound2": "No organizations found.",
"search": "Search…", "search": "Search…",
+1
View File
@@ -17,3 +17,4 @@ export * from "./verifyApiKeySiteResourceAccess";
export * from "./verifyApiKeyIdpAccess"; export * from "./verifyApiKeyIdpAccess";
export * from "./verifyApiKeyDomainAccess"; export * from "./verifyApiKeyDomainAccess";
export * from "./verifyApiKeyResourcePolicyAccess"; export * from "./verifyApiKeyResourcePolicyAccess";
export * from "./verifyApiKeySiteProvisioningKeyAccess";
@@ -0,0 +1,111 @@
import { Request, Response, NextFunction } from "express";
import {
db,
siteProvisioningKeys,
siteProvisioningKeyOrg,
apiKeyOrg
} from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeySiteProvisioningKeyAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const apiKey = req.apiKey;
const siteProvisioningKeyId =
getFirstString(req.params.siteProvisioningKeyId) ||
getFirstString(req.body.siteProvisioningKeyId) ||
getFirstString(req.query.siteProvisioningKeyId);
const orgId = getFirstString(req.params.orgId);
if (!apiKey) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
);
}
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
if (!siteProvisioningKeyId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID")
);
}
if (apiKey.isRoot) {
// Root keys can access any site provisioning key in any org
return next();
}
const [row] = await db
.select()
.from(siteProvisioningKeys)
.innerJoin(
siteProvisioningKeyOrg,
and(
eq(
siteProvisioningKeys.siteProvisioningKeyId,
siteProvisioningKeyOrg.siteProvisioningKeyId
),
eq(siteProvisioningKeyOrg.orgId, orgId)
)
)
.where(
eq(
siteProvisioningKeys.siteProvisioningKeyId,
siteProvisioningKeyId
)
)
.limit(1);
if (!row?.siteProvisioningKeys || !row.siteProvisioningKeyOrg) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site provisioning key with ID ${siteProvisioningKeyId} not found for organization ${orgId}`
)
);
}
if (!req.apiKeyOrg) {
const apiKeyOrgRes = await db
.select()
.from(apiKeyOrg)
.where(
and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, row.siteProvisioningKeyOrg.orgId)
)
)
.limit(1);
req.apiKeyOrg = apiKeyOrgRes[0];
}
if (!req.apiKeyOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to this organization"
)
);
}
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying site provisioning key access"
)
);
}
}
+1
View File
@@ -18,6 +18,7 @@ export enum OpenAPITags {
OrgIdp = "Identity Provider (Organization Only)", OrgIdp = "Identity Provider (Organization Only)",
Client = "Client", Client = "Client",
ApiKey = "API Key", ApiKey = "API Key",
SiteProvisioningKey = "Site Provisioning Key",
Domain = "Domain", Domain = "Domain",
Blueprint = "Blueprint", Blueprint = "Blueprint",
Ssh = "SSH", Ssh = "SSH",
+44
View File
@@ -16,6 +16,7 @@ import * as org from "#private/routers/org";
import * as logs from "#private/routers/auditLogs"; import * as logs from "#private/routers/auditLogs";
import * as alertEvents from "#private/routers/alertEvents"; import * as alertEvents from "#private/routers/alertEvents";
import * as certificates from "#private/routers/certificates"; import * as certificates from "#private/routers/certificates";
import * as siteProvisioning from "#private/routers/siteProvisioning";
import { import {
verifyApiKeyHasAction, verifyApiKeyHasAction,
@@ -24,6 +25,7 @@ import {
verifyApiKeyIdpAccess, verifyApiKeyIdpAccess,
verifyApiKeyRoleAccess, verifyApiKeyRoleAccess,
verifyApiKeyUserAccess, verifyApiKeyUserAccess,
verifyApiKeySiteProvisioningKeyAccess,
verifyLimits verifyLimits
} from "@server/middlewares"; } from "@server/middlewares";
import * as user from "#private/routers/user"; import * as user from "#private/routers/user";
@@ -215,3 +217,45 @@ authenticated.delete(
logActionAudit(ActionsEnum.removeUserRole), logActionAudit(ActionsEnum.removeUserRole),
user.removeUserRole user.removeUserRole
); );
authenticated.put(
"/org/:orgId/site-provisioning-key",
verifyValidLicense,
verifyValidSubscription(tierMatrix.siteProvisioningKeys),
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createSiteProvisioningKey),
logActionAudit(ActionsEnum.createSiteProvisioningKey),
siteProvisioning.createSiteProvisioningKey
);
authenticated.get(
"/org/:orgId/site-provisioning-keys",
verifyValidLicense,
verifyValidSubscription(tierMatrix.siteProvisioningKeys),
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.listSiteProvisioningKeys),
siteProvisioning.listSiteProvisioningKeys
);
authenticated.delete(
"/org/:orgId/site-provisioning-key/:siteProvisioningKeyId",
verifyValidLicense,
verifyValidSubscription(tierMatrix.siteProvisioningKeys),
verifyApiKeyOrgAccess,
verifyApiKeySiteProvisioningKeyAccess,
verifyApiKeyHasAction(ActionsEnum.deleteSiteProvisioningKey),
logActionAudit(ActionsEnum.deleteSiteProvisioningKey),
siteProvisioning.deleteSiteProvisioningKey
);
authenticated.patch(
"/org/:orgId/site-provisioning-key/:siteProvisioningKeyId",
verifyValidLicense,
verifyValidSubscription(tierMatrix.siteProvisioningKeys),
verifyApiKeyOrgAccess,
verifyApiKeySiteProvisioningKeyAccess,
verifyApiKeyHasAction(ActionsEnum.updateSiteProvisioningKey),
logActionAudit(ActionsEnum.updateSiteProvisioningKey),
siteProvisioning.updateSiteProvisioningKey
);
@@ -26,6 +26,8 @@ import {
import logger from "@server/logger"; import logger from "@server/logger";
import { hashPassword } from "@server/auth/password"; import { hashPassword } from "@server/auth/password";
import type { CreateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types"; import type { CreateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z.object({ const paramsSchema = z.object({
orgId: z.string().nonempty() orgId: z.string().nonempty()
@@ -34,10 +36,17 @@ const paramsSchema = z.object({
const bodySchema = z const bodySchema = z
.strictObject({ .strictObject({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
maxBatchSize: z.union([ maxBatchSize: z
z.null(), .union([
z.coerce.number().int().positive().max(1_000_000) z.null(),
]), z.coerce.number().int().positive().max(1_000_000)
])
.openapi({
type: "number",
default: null,
description:
"Maximum number of sites that can be provisioned in a single batch. If null, there is no limit."
}),
validUntil: z.string().max(255).optional(), validUntil: z.string().max(255).optional(),
approveNewSites: z.boolean().optional().default(true) approveNewSites: z.boolean().optional().default(true)
}) })
@@ -57,6 +66,49 @@ const bodySchema = z
export type CreateSiteProvisioningKeyBody = z.infer<typeof bodySchema>; export type CreateSiteProvisioningKeyBody = z.infer<typeof bodySchema>;
const CreateSiteProvisioningKeyResponseDataSchema = z.object({
siteProvisioningKeyId: z.string(),
orgId: z.string(),
name: z.string(),
siteProvisioningKey: z.string(),
lastChars: z.string(),
createdAt: z.string(),
lastUsed: z.string().nullable(),
maxBatchSize: z.number().nullable(),
numUsed: z.number(),
validUntil: z.string().nullable(),
approveNewSites: z.boolean()
});
registry.registerPath({
method: "put",
path: "/org/{orgId}/site-provisioning-key",
description: "Create a new site provisioning key for the organization.",
tags: [OpenAPITags.SiteProvisioningKey],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
201: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(
CreateSiteProvisioningKeyResponseDataSchema
)
}
}
}
}
});
export async function createSiteProvisioningKey( export async function createSiteProvisioningKey(
req: Request, req: Request,
res: Response, res: Response,
@@ -13,23 +13,46 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db";
db,
siteProvisioningKeyOrg,
siteProvisioningKeys
} from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z.object({ const paramsSchema = z.object({
siteProvisioningKeyId: z.string().nonempty(), siteProvisioningKeyId: z.string().nonempty(),
orgId: z.string().nonempty() orgId: z.string().nonempty()
}); });
registry.registerPath({
method: "delete",
path: "/org/{orgId}/site-provisioning-key/{siteProvisioningKeyId}",
description: "Delete a site provisioning key.",
tags: [OpenAPITags.SiteProvisioningKey],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function deleteSiteProvisioningKey( export async function deleteSiteProvisioningKey(
req: Request, req: Request,
res: Response, res: Response,
@@ -11,11 +11,7 @@
* This file is not licensed under the AGPLv3. * This file is not licensed under the AGPLv3.
*/ */
import { import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db";
db,
siteProvisioningKeyOrg,
siteProvisioningKeys
} from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response"; import response from "@server/lib/response";
@@ -25,6 +21,8 @@ import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types"; import type { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z.object({ const paramsSchema = z.object({
orgId: z.string().nonempty() orgId: z.string().nonempty()
@@ -45,11 +43,55 @@ const querySchema = z.object({
.pipe(z.int().nonnegative()) .pipe(z.int().nonnegative())
}); });
const ListSiteProvisioningKeysResponseDataSchema = z.object({
siteProvisioningKeys: z.array(
z.object({
siteProvisioningKeyId: z.string(),
orgId: z.string(),
lastChars: z.string(),
createdAt: z.string(),
name: z.string(),
lastUsed: z.string().nullable(),
maxBatchSize: z.number().nullable(),
numUsed: z.number(),
validUntil: z.string().nullable(),
approveNewSites: z.boolean()
})
),
pagination: z.object({
total: z.number(),
limit: z.number(),
offset: z.number()
})
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/site-provisioning-keys",
description: "List all site provisioning keys for an organization.",
tags: [OpenAPITags.SiteProvisioningKey],
request: {
params: paramsSchema,
query: querySchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(
ListSiteProvisioningKeysResponseDataSchema
)
}
}
}
}
});
function querySiteProvisioningKeys(orgId: string) { function querySiteProvisioningKeys(orgId: string) {
return db return db
.select({ .select({
siteProvisioningKeyId: siteProvisioningKeyId: siteProvisioningKeys.siteProvisioningKeyId,
siteProvisioningKeys.siteProvisioningKeyId,
orgId: siteProvisioningKeyOrg.orgId, orgId: siteProvisioningKeyOrg.orgId,
lastChars: siteProvisioningKeys.lastChars, lastChars: siteProvisioningKeys.lastChars,
createdAt: siteProvisioningKeys.createdAt, createdAt: siteProvisioningKeys.createdAt,
@@ -13,11 +13,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db";
db,
siteProvisioningKeyOrg,
siteProvisioningKeys
} from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -25,6 +21,8 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import type { UpdateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types"; import type { UpdateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z.object({ const paramsSchema = z.object({
siteProvisioningKeyId: z.string().nonempty(), siteProvisioningKeyId: z.string().nonempty(),
@@ -38,7 +36,13 @@ const bodySchema = z
z.null(), z.null(),
z.coerce.number().int().positive().max(1_000_000) z.coerce.number().int().positive().max(1_000_000)
]) ])
.optional(), .optional()
.openapi({
type: "number",
default: null,
description:
"Maximum number of sites that can be provisioned in a single batch. If null, there is no limit."
}),
validUntil: z.string().max(255).optional(), validUntil: z.string().max(255).optional(),
approveNewSites: z.boolean().optional() approveNewSites: z.boolean().optional()
}) })
@@ -50,7 +54,8 @@ const bodySchema = z
) { ) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
message: "Provide maxBatchSize and/or validUntil and/or approveNewSites", message:
"Provide maxBatchSize and/or validUntil and/or approveNewSites",
path: ["maxBatchSize"] path: ["maxBatchSize"]
}); });
} }
@@ -69,6 +74,48 @@ const bodySchema = z
export type UpdateSiteProvisioningKeyBody = z.infer<typeof bodySchema>; export type UpdateSiteProvisioningKeyBody = z.infer<typeof bodySchema>;
const UpdateSiteProvisioningKeyResponseDataSchema = z.object({
siteProvisioningKeyId: z.string(),
orgId: z.string(),
name: z.string(),
lastChars: z.string(),
createdAt: z.string(),
lastUsed: z.string().nullable(),
maxBatchSize: z.number().nullable(),
numUsed: z.number(),
validUntil: z.string().nullable(),
approveNewSites: z.boolean()
});
registry.registerPath({
method: "patch",
path: "/org/{orgId}/site-provisioning-key/{siteProvisioningKeyId}",
description: "Update a site provisioning key.",
tags: [OpenAPITags.SiteProvisioningKey],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(
UpdateSiteProvisioningKeyResponseDataSchema
)
}
}
}
}
});
export async function updateSiteProvisioningKey( export async function updateSiteProvisioningKey(
req: Request, req: Request,
res: Response, res: Response,
+7
View File
@@ -143,6 +143,13 @@ function getActionsCategories(root: boolean) {
Logs: { Logs: {
[t("actionExportLogs")]: "exportLogs", [t("actionExportLogs")]: "exportLogs",
[t("actionViewLogs")]: "viewLogs" [t("actionViewLogs")]: "viewLogs"
},
"Site Provisioning Key": {
[t("actionCreateSiteProvisioningKey")]: "createSiteProvisioningKey",
[t("actionListSiteProvisioningKeys")]: "listSiteProvisioningKeys",
[t("actionUpdateSiteProvisioningKey")]: "updateSiteProvisioningKey",
[t("actionDeleteSiteProvisioningKey")]: "deleteSiteProvisioningKey"
} }
}; };