diff --git a/messages/en-US.json b/messages/en-US.json index 6893b41fa..678c73ebd 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1511,6 +1511,10 @@ "actionListInvitations": "List Invitations", "actionExportLogs": "Export 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", "orgNotFound2": "No organizations found.", "search": "Search…", diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts index d43854eab..be9d400cf 100644 --- a/server/middlewares/integration/index.ts +++ b/server/middlewares/integration/index.ts @@ -17,3 +17,4 @@ export * from "./verifyApiKeySiteResourceAccess"; export * from "./verifyApiKeyIdpAccess"; export * from "./verifyApiKeyDomainAccess"; export * from "./verifyApiKeyResourcePolicyAccess"; +export * from "./verifyApiKeySiteProvisioningKeyAccess"; diff --git a/server/middlewares/integration/verifyApiKeySiteProvisioningKeyAccess.ts b/server/middlewares/integration/verifyApiKeySiteProvisioningKeyAccess.ts new file mode 100644 index 000000000..d1982ad3c --- /dev/null +++ b/server/middlewares/integration/verifyApiKeySiteProvisioningKeyAccess.ts @@ -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" + ) + ); + } +} diff --git a/server/openApi.ts b/server/openApi.ts index 7132a0327..63625192d 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -18,6 +18,7 @@ export enum OpenAPITags { OrgIdp = "Identity Provider (Organization Only)", Client = "Client", ApiKey = "API Key", + SiteProvisioningKey = "Site Provisioning Key", Domain = "Domain", Blueprint = "Blueprint", Ssh = "SSH", diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 820a843f0..b613b2f27 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -16,6 +16,7 @@ import * as org from "#private/routers/org"; import * as logs from "#private/routers/auditLogs"; import * as alertEvents from "#private/routers/alertEvents"; import * as certificates from "#private/routers/certificates"; +import * as siteProvisioning from "#private/routers/siteProvisioning"; import { verifyApiKeyHasAction, @@ -24,6 +25,7 @@ import { verifyApiKeyIdpAccess, verifyApiKeyRoleAccess, verifyApiKeyUserAccess, + verifyApiKeySiteProvisioningKeyAccess, verifyLimits } from "@server/middlewares"; import * as user from "#private/routers/user"; @@ -215,3 +217,45 @@ authenticated.delete( logActionAudit(ActionsEnum.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 +); diff --git a/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts index f5b64c0f3..b810ebe4f 100644 --- a/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts +++ b/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts @@ -26,6 +26,8 @@ import { import logger from "@server/logger"; import { hashPassword } from "@server/auth/password"; 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({ orgId: z.string().nonempty() @@ -34,10 +36,17 @@ const paramsSchema = z.object({ const bodySchema = z .strictObject({ name: z.string().min(1).max(255), - maxBatchSize: z.union([ - z.null(), - z.coerce.number().int().positive().max(1_000_000) - ]), + maxBatchSize: z + .union([ + 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(), approveNewSites: z.boolean().optional().default(true) }) @@ -57,6 +66,49 @@ const bodySchema = z export type CreateSiteProvisioningKeyBody = z.infer; +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( req: Request, res: Response, diff --git a/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts index 61fa0a850..9c77c93ca 100644 --- a/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts +++ b/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts @@ -13,23 +13,46 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { - db, - siteProvisioningKeyOrg, - siteProvisioningKeys -} from "@server/db"; +import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z.object({ siteProvisioningKeyId: 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( req: Request, res: Response, diff --git a/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts b/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts index 0e9838b24..63ab4429a 100644 --- a/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts +++ b/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts @@ -11,11 +11,7 @@ * This file is not licensed under the AGPLv3. */ -import { - db, - siteProvisioningKeyOrg, - siteProvisioningKeys -} from "@server/db"; +import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; @@ -25,6 +21,8 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { eq } from "drizzle-orm"; 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({ orgId: z.string().nonempty() @@ -45,11 +43,55 @@ const querySchema = z.object({ .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) { return db .select({ - siteProvisioningKeyId: - siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId: siteProvisioningKeys.siteProvisioningKeyId, orgId: siteProvisioningKeyOrg.orgId, lastChars: siteProvisioningKeys.lastChars, createdAt: siteProvisioningKeys.createdAt, diff --git a/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts index cee40212a..f97389b2b 100644 --- a/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts +++ b/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts @@ -13,11 +13,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { - db, - siteProvisioningKeyOrg, - siteProvisioningKeys -} from "@server/db"; +import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -25,6 +21,8 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; 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({ siteProvisioningKeyId: z.string().nonempty(), @@ -38,7 +36,13 @@ const bodySchema = z z.null(), 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(), approveNewSites: z.boolean().optional() }) @@ -50,7 +54,8 @@ const bodySchema = z ) { ctx.addIssue({ code: "custom", - message: "Provide maxBatchSize and/or validUntil and/or approveNewSites", + message: + "Provide maxBatchSize and/or validUntil and/or approveNewSites", path: ["maxBatchSize"] }); } @@ -69,6 +74,48 @@ const bodySchema = z export type UpdateSiteProvisioningKeyBody = z.infer; +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( req: Request, res: Response, diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx index 48a492c31..48868722e 100644 --- a/src/components/PermissionsSelectBox.tsx +++ b/src/components/PermissionsSelectBox.tsx @@ -143,6 +143,13 @@ function getActionsCategories(root: boolean) { Logs: { [t("actionExportLogs")]: "exportLogs", [t("actionViewLogs")]: "viewLogs" + }, + + "Site Provisioning Key": { + [t("actionCreateSiteProvisioningKey")]: "createSiteProvisioningKey", + [t("actionListSiteProvisioningKeys")]: "listSiteProvisioningKeys", + [t("actionUpdateSiteProvisioningKey")]: "updateSiteProvisioningKey", + [t("actionDeleteSiteProvisioningKey")]: "deleteSiteProvisioningKey" } };