move to private routes

This commit is contained in:
miloschwartz
2026-03-24 20:27:15 -07:00
parent 212b7a104f
commit 3525b367b3
24 changed files with 1054 additions and 103 deletions

View File

@@ -111,6 +111,7 @@ export enum ActionsEnum {
getApiKey = "getApiKey",
createSiteProvisioningKey = "createSiteProvisioningKey",
listSiteProvisioningKeys = "listSiteProvisioningKeys",
updateSiteProvisioningKey = "updateSiteProvisioningKey",
deleteSiteProvisioningKey = "deleteSiteProvisioningKey",
getCertificate = "getCertificate",
restartCertificate = "restartCertificate",

View File

@@ -387,7 +387,11 @@ export const siteProvisioningKeys = pgTable("siteProvisioningKeys", {
name: varchar("name", { length: 255 }).notNull(),
siteProvisioningKeyHash: text("siteProvisioningKeyHash").notNull(),
lastChars: varchar("lastChars", { length: 4 }).notNull(),
createdAt: varchar("dateCreated", { length: 255 }).notNull()
createdAt: varchar("dateCreated", { length: 255 }).notNull(),
lastUsed: varchar("lastUsed", { length: 255 }),
maxBatchSize: integer("maxBatchSize"), // null = no limit
numUsed: integer("numUsed").notNull().default(0),
validUntil: varchar("validUntil", { length: 255 })
});
export const siteProvisioningKeyOrg = pgTable(

View File

@@ -371,7 +371,11 @@ export const siteProvisioningKeys = sqliteTable("siteProvisioningKeys", {
name: text("name").notNull(),
siteProvisioningKeyHash: text("siteProvisioningKeyHash").notNull(),
lastChars: text("lastChars").notNull(),
createdAt: text("dateCreated").notNull()
createdAt: text("dateCreated").notNull(),
lastUsed: text("lastUsed"),
maxBatchSize: integer("maxBatchSize"), // null = no limit
numUsed: integer("numUsed").notNull().default(0),
validUntil: text("validUntil")
});
export const siteProvisioningKeyOrg = sqliteTable(

View File

@@ -16,7 +16,8 @@ export enum TierFeature {
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration
AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning
SshPam = "sshPam"
SshPam = "sshPam",
SiteProvisioningKeys = "siteProvisioningKeys" // handle downgrade by revoking keys if needed
}
export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -50,5 +51,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
"enterprise"
],
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"]
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
[TierFeature.SiteProvisioningKeys]: ["enterprise"]
};

View File

@@ -26,9 +26,11 @@ import {
orgs,
resources,
roles,
siteProvisioningKeyOrg,
siteProvisioningKeys,
siteResources
} from "@server/db";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
/**
* Get the maximum allowed retention days for a given tier
@@ -291,6 +293,10 @@ async function disableFeature(
await disableSshPam(orgId);
break;
case TierFeature.SiteProvisioningKeys:
await disableSiteProvisioningKeys(orgId);
break;
default:
logger.warn(
`Unknown feature ${feature} for org ${orgId}, skipping`
@@ -326,6 +332,57 @@ async function disableSshPam(orgId: string): Promise<void> {
);
}
async function disableSiteProvisioningKeys(orgId: string): Promise<void> {
const rows = await db
.select({
siteProvisioningKeyId:
siteProvisioningKeyOrg.siteProvisioningKeyId
})
.from(siteProvisioningKeyOrg)
.where(eq(siteProvisioningKeyOrg.orgId, orgId));
for (const { siteProvisioningKeyId } of rows) {
await db.transaction(async (trx) => {
await trx
.delete(siteProvisioningKeyOrg)
.where(
and(
eq(
siteProvisioningKeyOrg.siteProvisioningKeyId,
siteProvisioningKeyId
),
eq(siteProvisioningKeyOrg.orgId, orgId)
)
);
const remaining = await trx
.select()
.from(siteProvisioningKeyOrg)
.where(
eq(
siteProvisioningKeyOrg.siteProvisioningKeyId,
siteProvisioningKeyId
)
);
if (remaining.length === 0) {
await trx
.delete(siteProvisioningKeys)
.where(
eq(
siteProvisioningKeys.siteProvisioningKeyId,
siteProvisioningKeyId
)
);
}
});
}
logger.info(
`Removed site provisioning keys for org ${orgId} after tier downgrade`
);
}
async function disableLoginPageBranding(orgId: string): Promise<void> {
const [existingBranding] = await db
.select()

View File

@@ -26,6 +26,7 @@ import * as misc from "#private/routers/misc";
import * as reKey from "#private/routers/re-key";
import * as approval from "#private/routers/approvals";
import * as ssh from "#private/routers/ssh";
import * as siteProvisioning from "#private/routers/siteProvisioning";
import {
verifyOrgAccess,
@@ -33,7 +34,8 @@ import {
verifyUserIsServerAdmin,
verifySiteAccess,
verifyClientAccess,
verifyLimits
verifyLimits,
verifySiteProvisioningKeyAccess
} from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions";
import {
@@ -537,3 +539,45 @@ authenticated.post(
// logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata
ssh.signSshKey
);
authenticated.put(
"/org/:orgId/site-provisioning-key",
verifyValidLicense,
verifyValidSubscription(tierMatrix.siteProvisioningKeys),
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createSiteProvisioningKey),
logActionAudit(ActionsEnum.createSiteProvisioningKey),
siteProvisioning.createSiteProvisioningKey
);
authenticated.get(
"/org/:orgId/site-provisioning-keys",
verifyValidLicense,
verifyValidSubscription(tierMatrix.siteProvisioningKeys),
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listSiteProvisioningKeys),
siteProvisioning.listSiteProvisioningKeys
);
authenticated.delete(
"/org/:orgId/site-provisioning-key/:siteProvisioningKeyId",
verifyValidLicense,
verifyValidSubscription(tierMatrix.siteProvisioningKeys),
verifyOrgAccess,
verifySiteProvisioningKeyAccess,
verifyUserHasAction(ActionsEnum.deleteSiteProvisioningKey),
logActionAudit(ActionsEnum.deleteSiteProvisioningKey),
siteProvisioning.deleteSiteProvisioningKey
);
authenticated.patch(
"/org/:orgId/site-provisioning-key/:siteProvisioningKeyId",
verifyValidLicense,
verifyValidSubscription(tierMatrix.siteProvisioningKeys),
verifyOrgAccess,
verifySiteProvisioningKeyAccess,
verifyUserHasAction(ActionsEnum.updateSiteProvisioningKey),
logActionAudit(ActionsEnum.updateSiteProvisioningKey),
siteProvisioning.updateSiteProvisioningKey
);

View File

@@ -1,3 +1,16 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { NextFunction, Request, Response } from "express";
import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db";
import HttpCode from "@server/types/HttpCode";
@@ -12,26 +25,37 @@ import {
} from "@server/auth/sessions/app";
import logger from "@server/logger";
import { hashPassword } from "@server/auth/password";
import type { CreateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types";
const paramsSchema = z.object({
orgId: z.string().nonempty()
});
const bodySchema = z.strictObject({
name: z.string().min(1).max(255)
});
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)
]),
validUntil: z.string().max(255).optional()
})
.superRefine((data, ctx) => {
const v = data.validUntil;
if (v == null || v.trim() === "") {
return;
}
if (Number.isNaN(Date.parse(v))) {
ctx.addIssue({
code: "custom",
message: "Invalid validUntil",
path: ["validUntil"]
});
}
});
export type CreateSiteProvisioningKeyBody = z.infer<typeof bodySchema>;
export type CreateSiteProvisioningKeyResponse = {
siteProvisioningKeyId: string;
orgId: string;
name: string;
siteProvisioningKey: string;
lastChars: string;
createdAt: string;
};
export async function createSiteProvisioningKey(
req: Request,
res: Response,
@@ -58,7 +82,12 @@ export async function createSiteProvisioningKey(
}
const { orgId } = parsedParams.data;
const { name } = parsedBody.data;
const { name, maxBatchSize } = parsedBody.data;
const vuRaw = parsedBody.data.validUntil;
const validUntil =
vuRaw == null || vuRaw.trim() === ""
? null
: new Date(Date.parse(vuRaw)).toISOString();
const siteProvisioningKeyId = `spk-${generateId(15)}`;
const siteProvisioningKey = generateIdFromEntropySize(25);
@@ -72,7 +101,11 @@ export async function createSiteProvisioningKey(
name,
siteProvisioningKeyHash,
createdAt,
lastChars
lastChars,
lastUsed: null,
maxBatchSize,
numUsed: 0,
validUntil
});
await trx.insert(siteProvisioningKeyOrg).values({
@@ -89,7 +122,11 @@ export async function createSiteProvisioningKey(
name,
siteProvisioningKey,
lastChars,
createdAt
createdAt,
lastUsed: null,
maxBatchSize,
numUsed: 0,
validUntil
},
success: true,
error: false,

View File

@@ -1,3 +1,16 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {

View File

@@ -0,0 +1,17 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
export * from "./createSiteProvisioningKey";
export * from "./listSiteProvisioningKeys";
export * from "./deleteSiteProvisioningKey";
export * from "./updateSiteProvisioningKey";

View File

@@ -1,3 +1,16 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import {
db,
siteProvisioningKeyOrg,
@@ -11,6 +24,7 @@ import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { eq } from "drizzle-orm";
import type { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types";
const paramsSchema = z.object({
orgId: z.string().nonempty()
@@ -39,7 +53,11 @@ function querySiteProvisioningKeys(orgId: string) {
orgId: siteProvisioningKeyOrg.orgId,
lastChars: siteProvisioningKeys.lastChars,
createdAt: siteProvisioningKeys.createdAt,
name: siteProvisioningKeys.name
name: siteProvisioningKeys.name,
lastUsed: siteProvisioningKeys.lastUsed,
maxBatchSize: siteProvisioningKeys.maxBatchSize,
numUsed: siteProvisioningKeys.numUsed,
validUntil: siteProvisioningKeys.validUntil
})
.from(siteProvisioningKeyOrg)
.innerJoin(
@@ -52,13 +70,6 @@ function querySiteProvisioningKeys(orgId: string) {
.where(eq(siteProvisioningKeyOrg.orgId, orgId));
}
export type ListSiteProvisioningKeysResponse = {
siteProvisioningKeys: Awaited<
ReturnType<typeof querySiteProvisioningKeys>
>;
pagination: { total: number; limit: number; offset: number };
};
export async function listSiteProvisioningKeys(
req: Request,
res: Response,

View File

@@ -0,0 +1,199 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
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 type { UpdateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types";
const paramsSchema = z.object({
siteProvisioningKeyId: z.string().nonempty(),
orgId: z.string().nonempty()
});
const bodySchema = z
.strictObject({
maxBatchSize: z
.union([
z.null(),
z.coerce.number().int().positive().max(1_000_000)
])
.optional(),
validUntil: z.string().max(255).optional()
})
.superRefine((data, ctx) => {
if (
data.maxBatchSize === undefined &&
data.validUntil === undefined
) {
ctx.addIssue({
code: "custom",
message: "Provide maxBatchSize and/or validUntil",
path: ["maxBatchSize"]
});
}
const v = data.validUntil;
if (v == null || v.trim() === "") {
return;
}
if (Number.isNaN(Date.parse(v))) {
ctx.addIssue({
code: "custom",
message: "Invalid validUntil",
path: ["validUntil"]
});
}
});
export type UpdateSiteProvisioningKeyBody = z.infer<typeof bodySchema>;
export async function updateSiteProvisioningKey(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { siteProvisioningKeyId, orgId } = parsedParams.data;
const body = parsedBody.data;
const [row] = await db
.select()
.from(siteProvisioningKeys)
.where(
eq(
siteProvisioningKeys.siteProvisioningKeyId,
siteProvisioningKeyId
)
)
.innerJoin(
siteProvisioningKeyOrg,
and(
eq(
siteProvisioningKeys.siteProvisioningKeyId,
siteProvisioningKeyOrg.siteProvisioningKeyId
),
eq(siteProvisioningKeyOrg.orgId, orgId)
)
)
.limit(1);
if (!row) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site provisioning key with ID ${siteProvisioningKeyId} not found`
)
);
}
const setValues: {
maxBatchSize?: number | null;
validUntil?: string | null;
} = {};
if (body.maxBatchSize !== undefined) {
setValues.maxBatchSize = body.maxBatchSize;
}
if (body.validUntil !== undefined) {
setValues.validUntil =
body.validUntil.trim() === ""
? null
: new Date(Date.parse(body.validUntil)).toISOString();
}
await db
.update(siteProvisioningKeys)
.set(setValues)
.where(
eq(
siteProvisioningKeys.siteProvisioningKeyId,
siteProvisioningKeyId
)
);
const [updated] = await db
.select({
siteProvisioningKeyId:
siteProvisioningKeys.siteProvisioningKeyId,
name: siteProvisioningKeys.name,
lastChars: siteProvisioningKeys.lastChars,
createdAt: siteProvisioningKeys.createdAt,
lastUsed: siteProvisioningKeys.lastUsed,
maxBatchSize: siteProvisioningKeys.maxBatchSize,
numUsed: siteProvisioningKeys.numUsed,
validUntil: siteProvisioningKeys.validUntil
})
.from(siteProvisioningKeys)
.where(
eq(
siteProvisioningKeys.siteProvisioningKeyId,
siteProvisioningKeyId
)
)
.limit(1);
if (!updated) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to load updated site provisioning key"
)
);
}
return response<UpdateSiteProvisioningKeyResponse>(res, {
data: {
...updated,
orgId
},
success: true,
error: false,
message: "Site provisioning key updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -15,7 +15,6 @@ import * as accessToken from "./accessToken";
import * as idp from "./idp";
import * as blueprints from "./blueprints";
import * as apiKeys from "./apiKeys";
import * as siteProvisioning from "./siteProvisioning";
import * as logs from "./auditLogs";
import * as newt from "./newt";
import * as olm from "./olm";
@@ -43,8 +42,7 @@ import {
verifyUserIsOrgOwner,
verifySiteResourceAccess,
verifyOlmAccess,
verifyLimits,
verifySiteProvisioningKeyAccess
verifyLimits
} from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions";
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
@@ -988,31 +986,6 @@ authenticated.get(
apiKeys.listRootApiKeys
);
authenticated.put(
`/org/:orgId/site-provisioning-key`,
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createSiteProvisioningKey),
logActionAudit(ActionsEnum.createSiteProvisioningKey),
siteProvisioning.createSiteProvisioningKey
);
authenticated.get(
`/org/:orgId/site-provisioning-keys`,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listSiteProvisioningKeys),
siteProvisioning.listSiteProvisioningKeys
);
authenticated.delete(
`/org/:orgId/site-provisioning-key/:siteProvisioningKeyId`,
verifyOrgAccess,
verifySiteProvisioningKeyAccess,
verifyUserHasAction(ActionsEnum.deleteSiteProvisioningKey),
logActionAudit(ActionsEnum.deleteSiteProvisioningKey),
siteProvisioning.deleteSiteProvisioningKey
);
authenticated.get(
`/api-key/:apiKeyId/actions`,
verifyUserIsServerAdmin,

View File

@@ -1,3 +0,0 @@
export * from "./createSiteProvisioningKey";
export * from "./listSiteProvisioningKeys";
export * from "./deleteSiteProvisioningKey";

View File

@@ -0,0 +1,41 @@
export type SiteProvisioningKeyListItem = {
siteProvisioningKeyId: string;
orgId: string;
lastChars: string;
createdAt: string;
name: string;
lastUsed: string | null;
maxBatchSize: number | null;
numUsed: number;
validUntil: string | null;
};
export type ListSiteProvisioningKeysResponse = {
siteProvisioningKeys: SiteProvisioningKeyListItem[];
pagination: { total: number; limit: number; offset: number };
};
export type CreateSiteProvisioningKeyResponse = {
siteProvisioningKeyId: string;
orgId: string;
name: string;
siteProvisioningKey: string;
lastChars: string;
createdAt: string;
lastUsed: string | null;
maxBatchSize: number | null;
numUsed: number;
validUntil: string | null;
};
export type UpdateSiteProvisioningKeyResponse = {
siteProvisioningKeyId: string;
orgId: string;
name: string;
lastChars: string;
createdAt: string;
lastUsed: string | null;
maxBatchSize: number | null;
numUsed: number;
validUntil: string | null;
};