CREDENTIAL TYPE MANAGEMENT WORKS

This commit is contained in:
2026-02-14 15:15:49 -06:00
parent b7637334a6
commit cdae4d47a4
46 changed files with 7621 additions and 41 deletions
+43
View File
@@ -0,0 +1,43 @@
import { Hono } from "hono/tiny";
import { createRoute } from "../../modules/api-utils/createRoute";
import { credentialTypes } from "../../managers/credentialTypes";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { z } from "zod";
/* /v1/credential-type */
export default createRoute(
"post",
["/"],
async (c) => {
const body = await c.req.json();
const schema = z.object({
name: z.string().min(1, "Name is required"),
permissionScope: z.string().min(1, "Permission scope is required"),
icon: z.string().optional(),
fields: z.array(
z.object({
id: z.string(),
name: z.string(),
required: z.boolean(),
secure: z.boolean(),
valueType: z.enum(["plain_text", "password"]),
}),
),
});
const data = schema.parse(body);
const credentialType = await credentialTypes.create(data as any);
const response = apiResponse.created(
"Credential Type Created Successfully!",
credentialType.toJson(),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["credential_type.create"] }),
);
+23
View File
@@ -0,0 +1,23 @@
import { Hono } from "hono/tiny";
import { createRoute } from "../../modules/api-utils/createRoute";
import { credentialTypes } from "../../managers/credentialTypes";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
/* /v1/credential-type/:id */
export default createRoute(
"delete",
["/:id"],
async (c) => {
await credentialTypes.delete(c.req.param("id"));
const response = apiResponse.successful(
"Credential Type Deleted Successfully!",
null,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["credential_type.delete"] }),
);
+25
View File
@@ -0,0 +1,25 @@
import { Hono } from "hono/tiny";
import { createRoute } from "../../modules/api-utils/createRoute";
import { credentialTypes } from "../../managers/credentialTypes";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
/* /v1/credential-type/:identifier */
export default createRoute(
"get",
["/:identifier"],
async (c) => {
const credentialType = await credentialTypes.fetch(
c.req.param("identifier"),
);
const response = apiResponse.successful(
"Credential Type Fetched Successfully!",
credentialType.toJson({ includeCredentialCount: true }),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["credential_type.fetch"] }),
);
+25
View File
@@ -0,0 +1,25 @@
import { Hono } from "hono/tiny";
import { createRoute } from "../../modules/api-utils/createRoute";
import { credentialTypes } from "../../managers/credentialTypes";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
/* /v1/credential-type */
export default createRoute(
"get",
["/"],
async (c) => {
const allCredentialTypes = await credentialTypes.fetchAll();
const response = apiResponse.successful(
"Credential Types Fetched Successfully!",
allCredentialTypes.map((ct) =>
ct.toJson({ includeCredentialCount: true }),
),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["credential_type.fetch.many"] }),
);
@@ -0,0 +1,26 @@
import { Hono } from "hono/tiny";
import { createRoute } from "../../modules/api-utils/createRoute";
import { credentialTypes } from "../../managers/credentialTypes";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
/* /v1/credential-type/:id/credentials */
export default createRoute(
"get",
["/:id/credentials"],
async (c) => {
const credentialType = await credentialTypes.fetch(c.req.param("id"));
const credentials = await credentialType.fetchCredentials();
const response = apiResponse.successful(
"Credentials Fetched Successfully!",
credentials.map((cred) => cred.toJson()),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({
permissions: ["credential_type.fetch", "credential.fetch.many"],
}),
);
+15
View File
@@ -0,0 +1,15 @@
import { default as fetch } from "./fetch";
import { default as fetchAll } from "./fetchAll";
import { default as create } from "./create";
import { default as update } from "./update";
import { default as deleteCredentialType } from "./delete";
import { default as fetchCredentials } from "./fetchCredentials";
export {
fetch,
fetchAll,
create,
update,
deleteCredentialType as delete,
fetchCredentials,
};
+46
View File
@@ -0,0 +1,46 @@
import { Hono } from "hono/tiny";
import { createRoute } from "../../modules/api-utils/createRoute";
import { credentialTypes } from "../../managers/credentialTypes";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { z } from "zod";
/* /v1/credential-type/:id */
export default createRoute(
"patch",
["/:id"],
async (c) => {
const body = await c.req.json();
const credentialType = await credentialTypes.fetch(c.req.param("id"));
const schema = z.object({
name: z.string().optional(),
permissionScope: z.string().optional(),
icon: z.string().optional(),
fields: z
.array(
z.object({
id: z.string(),
name: z.string(),
required: z.boolean(),
secure: z.boolean(),
valueType: z.enum(["plain_text", "password"]),
}),
)
.optional(),
});
const data = schema.parse(body);
await credentialType.update(data as any);
const response = apiResponse.successful(
"Credential Type Updated Successfully!",
credentialType.toJson(),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["credential_type.update"] }),
);
+41
View File
@@ -0,0 +1,41 @@
import { Hono } from "hono/tiny";
import { createRoute } from "../../modules/api-utils/createRoute";
import { credentials } from "../../managers/credentials";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { z } from "zod";
/* /v1/credential/create */
export default createRoute(
"post",
["/credentials"],
async (c) => {
const body = await c.req.json();
const schema = z.object({
name: z.string().min(1, "Name is required"),
typeId: z.string().min(1, "Type ID is required"),
companyId: z.string().min(1, "Company ID is required"),
fields: z.array(
z.object({
id: z.string(),
fieldId: z.string(),
value: z.string(),
}),
),
});
const data = schema.parse(body);
const credential = await credentials.create(data);
const response = apiResponse.created(
"Credential Created Successfully!",
credential.toJson(),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["credential.create"] }),
);
+23
View File
@@ -0,0 +1,23 @@
import { Hono } from "hono/tiny";
import { createRoute } from "../../modules/api-utils/createRoute";
import { credentials } from "../../managers/credentials";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
/* /v1/credential/:id */
export default createRoute(
"delete",
["/credentials/:id"],
async (c) => {
await credentials.delete(c.req.param("id"));
const response = apiResponse.successful(
"Credential Deleted Successfully!",
null,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["credential.delete"] }),
);
+23
View File
@@ -0,0 +1,23 @@
import { Hono } from "hono/tiny";
import { createRoute } from "../../modules/api-utils/createRoute";
import { credentials } from "../../managers/credentials";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
/* /v1/credential/:id */
export default createRoute(
"get",
["/credentials/:id"],
async (c) => {
const credential = await credentials.fetch(c.req.param("id"));
const response = apiResponse.successful(
"Credential Fetched Successfully!",
credential.toJson(),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["credential.fetch"] }),
);
+25
View File
@@ -0,0 +1,25 @@
import { Hono } from "hono/tiny";
import { createRoute } from "../../modules/api-utils/createRoute";
import { credentials } from "../../managers/credentials";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
/* /v1/credential/company/:companyId */
export default createRoute(
"get",
["/credentials/company/:companyId"],
async (c) => {
const companyCredentials = await credentials.fetchByCompany(
c.req.param("companyId"),
);
const response = apiResponse.successful(
"Company Credentials Fetched Successfully!",
companyCredentials.map((cred) => cred.toJson()),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["credential.fetch.many"] }),
);
+26
View File
@@ -0,0 +1,26 @@
import { Hono } from "hono/tiny";
import { createRoute } from "../../modules/api-utils/createRoute";
import { credentials } from "../../managers/credentials";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
/* /v1/credential/:id/fields */
export default createRoute(
"get",
["/credentials/:id/fields"],
async (c) => {
const credential = await credentials.fetch(c.req.param("id"));
const fields = await credential.fetchAllFieldValues();
const response = apiResponse.successful(
"Credential Fields Fetched Successfully!",
fields,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({
permissions: ["credential.fetch", "credential.fields.fetch"],
}),
);
+19
View File
@@ -0,0 +1,19 @@
import { default as fetch } from "./fetch";
import { default as fetchByCompany } from "./fetchByCompany";
import { default as create } from "./create";
import { default as update } from "./update";
import { default as updateFields } from "./updateFields";
import { default as fetchFields } from "./fetchFields";
import { default as readSecureValues } from "./readSecureValues";
import { default as deleteCredential } from "./delete";
export {
fetch,
fetchByCompany,
create,
update,
updateFields,
fetchFields,
readSecureValues,
deleteCredential as delete,
};
+26
View File
@@ -0,0 +1,26 @@
import { Hono } from "hono/tiny";
import { createRoute } from "../../modules/api-utils/createRoute";
import { credentials } from "../../managers/credentials";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
/* /v1/credential/:id/secure-values */
export default createRoute(
"get",
["/credentials/:id/secure-values"],
async (c) => {
const credential = await credentials.fetch(c.req.param("id"));
const secureValues = await credential.readAllSecureValues();
const response = apiResponse.successful(
"Secure Values Fetched Successfully!",
secureValues,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({
permissions: ["credential.fetch", "credential.secure_values.read"],
}),
);
+33
View File
@@ -0,0 +1,33 @@
import { Hono } from "hono/tiny";
import { createRoute } from "../../modules/api-utils/createRoute";
import { credentials } from "../../managers/credentials";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { z } from "zod";
/* /v1/credential/:id */
export default createRoute(
"patch",
["/credentials/:id"],
async (c) => {
const body = await c.req.json();
const credential = await credentials.fetch(c.req.param("id"));
const schema = z.object({
name: z.string().optional(),
});
const data = schema.parse(body);
await credential.update(data);
const response = apiResponse.successful(
"Credential Updated Successfully!",
credential.toJson(),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["credential.update"] }),
);
+41
View File
@@ -0,0 +1,41 @@
import { Hono } from "hono/tiny";
import { createRoute } from "../../modules/api-utils/createRoute";
import { credentials } from "../../managers/credentials";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { z } from "zod";
/* /v1/credential/:id/fields */
export default createRoute(
"put",
["/credentials/:id/fields"],
async (c) => {
const body = await c.req.json();
const credential = await credentials.fetch(c.req.param("id"));
const schema = z.object({
fields: z.array(
z.object({
id: z.string(),
fieldId: z.string(),
value: z.string(),
}),
),
});
const data = schema.parse(body);
await credential.validateAndUpdateFields(data.fields);
const response = apiResponse.successful(
"Credential Fields Updated Successfully!",
credential.toJson(),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({
permissions: ["credential.update", "credential.fields.update"],
}),
);
+7
View File
@@ -0,0 +1,7 @@
import { Hono } from "hono";
import * as credentialRoutes from "../credentials";
const credentialRouter = new Hono();
Object.values(credentialRoutes).map((r) => credentialRouter.route("/", r));
export default credentialRouter;
+9
View File
@@ -0,0 +1,9 @@
import { Hono } from "hono";
import * as credentialTypeRoutes from "../credential-types";
const credentialTypeRouter = new Hono();
Object.values(credentialTypeRoutes).map((r) =>
credentialTypeRouter.route("/", r),
);
export default credentialTypeRouter;
+2
View File
@@ -50,6 +50,8 @@ v1.route("/teapot", teapot);
v1.route("/auth", require("./routers/authRouter").default);
v1.route("/user", require("./routers/user").default);
v1.route("/company", require("./routers/companyRouter").default);
v1.route("/credential", require("./routers/credentialRouter").default);
v1.route("/credential-type", require("./routers/credentialTypeRouter").default);
app.route("/v1", v1);
export default app;
+9 -11
View File
@@ -23,18 +23,16 @@ export const sessionDuration = 30 * 24 * 60 * 60000;
export const accessTokenDuration = "10min";
export const refreshTokenDuration = "30d";
export const accessTokenPrivateKey = readFileSync(
`${import.meta.dir}/../.accessToken.key`,
export const accessTokenPrivateKey =
readFileSync(`.accessToken.key`).toString();
export const refreshTokenPrivateKey =
readFileSync(`.refreshToken.key`).toString();
export const permissionsPrivateKey = readFileSync(`.permissions.key`);
export const secureValuesPrivateKey =
readFileSync(`.secureValues.key`).toString();
export const secureValuesPublicKey = readFileSync(
`public-keys/.secureValues.pub`,
).toString();
export const refreshTokenPrivateKey = readFileSync(
`${import.meta.dir}/../.refreshToken.key`,
).toString();
export const permissionsPrivateKey = readFileSync(
`${import.meta.dir}/../.permissions.key`,
);
export const apiKeyTokenPrivateKey = readFileSync(
`${import.meta.dir}/../.apiKeyToken.key`,
);
// Microsoft Auth Constants
const msalConfig: msal.Configuration = {
+322
View File
@@ -0,0 +1,322 @@
import { z } from "zod";
import {
Credential,
CredentialType,
Company,
SecureValue,
} from "../../generated/prisma/client";
import { prisma } from "../constants";
import { fieldValidator } from "../modules/credentials/fieldValidator";
import {
CredentialField,
CredentialTypeField,
} from "../modules/credentials/credentialTypeDefs";
import { generateSecureValue } from "../modules/credentials/generateSecureValue";
import { readSecureValue } from "../modules/credentials/readSecureValue";
import GenericError from "../Errors/GenericError";
/**
* Credential Controller
*
* This class manages credential data, including field validation,
* secure value storage/retrieval, and credential updates.
*/
export class CredentialController {
public readonly id: string;
public name: string;
public readonly typeId: string;
public readonly companyId: string;
public fields: any;
private _type: CredentialType;
private _company: Company;
private _secureValues: SecureValue[];
public readonly createdAt: Date;
public updatedAt: Date;
constructor(
credentialData: Credential & {
type: CredentialType;
company: Company;
securevalues: SecureValue[];
},
) {
this.id = credentialData.id;
this.name = credentialData.name;
this.typeId = credentialData.typeId;
this.companyId = credentialData.companyId;
this.fields = credentialData.fields;
this._type = credentialData.type;
this._company = credentialData.company;
this._secureValues = credentialData.securevalues;
this.createdAt = credentialData.createdAt;
this.updatedAt = credentialData.updatedAt;
}
/**
* Update Internal Values
*
* Internal method to update all internal values when we query the database.
* This keeps everything up-to-date even when we pass around the credential controller.
*
* @param credentialData - Credential object from Prisma
*/
private _updateInternalValues(credentialData: Credential) {
this.name = credentialData.name;
this.fields = credentialData.fields;
this.updatedAt = credentialData.updatedAt;
}
/**
* Validate and Update Fields
*
* This method validates the submitted fields against the credential type's
* acceptable fields, then updates the credential in the database.
* Secure fields are encrypted and stored in the SecureValue table.
*
* @param fields - The fields to validate and update
* @returns {Promise<CredentialController>} - The updated credential controller
*/
async validateAndUpdateFields(
fields: CredentialField[],
): Promise<CredentialController> {
// Get acceptable fields from the credential type
const acceptableFields = this._type.fields as any as CredentialTypeField[];
// Validate the fields
const validatedFields = await fieldValidator(fields, acceptableFields);
// Separate secure and non-secure fields
const secureFields = validatedFields.filter((f) => f.secure);
const nonSecureFields = validatedFields.filter((f) => !f.secure);
// Process secure fields - encrypt and store in SecureValue table
await Promise.all(
secureFields.map(async (field) => {
const { encrypted, hash } = generateSecureValue(field.value);
// Check if a secure value already exists for this field
const existingSecureValue = await prisma.secureValue.findFirst({
where: {
credentialId: this.id,
name: field.fieldId,
},
});
if (existingSecureValue) {
// Update existing secure value
await prisma.secureValue.update({
where: { id: existingSecureValue.id },
data: {
content: encrypted,
hash,
},
});
} else {
// Create new secure value
await prisma.secureValue.create({
data: {
name: field.fieldId,
content: encrypted,
hash,
credentialId: this.id,
},
});
}
}),
);
// Build fields object for non-secure fields
const fieldsObject: Record<string, any> = {};
nonSecureFields.forEach((field) => {
fieldsObject[field.fieldId] = field.value;
});
// Update the credential with non-secure fields
const updatedCredential = await prisma.credential.update({
where: { id: this.id },
data: {
fields: fieldsObject,
},
});
this._updateInternalValues(updatedCredential);
// Refresh secure values
const secureValues = await prisma.secureValue.findMany({
where: { credentialId: this.id },
});
this._secureValues = secureValues;
return this;
}
/**
* Fetch All Field Values
*
* Returns all field values (both secure and non-secure) for this credential.
* Secure field values are NOT decrypted - use `readSecureFieldValue` for that.
*
* @returns {Promise<CredentialField[]>} - Array of all fields with their encrypted values
*/
async fetchAllFieldValues(): Promise<CredentialField[]> {
const fields: CredentialField[] = [];
// Add non-secure fields from the fields JSON
const nonSecureFields = this.fields as Record<string, any>;
Object.entries(nonSecureFields || {}).forEach(([fieldId, value]) => {
fields.push({
id: `${this.id}-${fieldId}`, // Generate a consistent ID
fieldId,
value: value as string,
});
});
// Add secure fields from SecureValue table (encrypted)
this._secureValues.forEach((secureValue) => {
fields.push({
id: secureValue.id,
fieldId: secureValue.name,
value: secureValue.content, // Encrypted value
});
});
return fields;
}
/**
* Read Secure Field Value
*
* Decrypts and returns the value of a specific secure field.
*
* @param fieldId - The field ID to read
* @returns {Promise<string>} - The decrypted field value
*/
async readSecureFieldValue(fieldId: string): Promise<string> {
const secureValue = this._secureValues.find((sv) => sv.name === fieldId);
if (!secureValue) {
throw new GenericError({
message: `Secure field not found: ${fieldId}`,
name: "SecureFieldNotFound",
cause: `No secure value exists for field '${fieldId}' in credential '${this.id}'`,
status: 404,
});
}
// Decrypt the value
const decryptedValue = readSecureValue(
secureValue.content,
secureValue.hash,
);
return decryptedValue;
}
/**
* Read All Secure Values
*
* Decrypts and returns all secure field values for this credential.
*
* @returns {Promise<Record<string, string>>} - Object mapping field IDs to decrypted values
*/
async readAllSecureValues(): Promise<Record<string, string>> {
const secureValues: Record<string, string> = {};
await Promise.all(
this._secureValues.map(async (secureValue) => {
const decryptedValue = readSecureValue(
secureValue.content,
secureValue.hash,
);
secureValues[secureValue.name] = decryptedValue;
}),
);
return secureValues;
}
/**
* Update Credential
*
* Update the credential name or other basic properties.
*
* @param data - Partial credential data to update
* @returns {Promise<CredentialController>} - The updated credential controller
*/
async update(
data: Partial<Pick<Credential, "name">>,
): Promise<CredentialController> {
const pData = z
.object({
name: z.string().optional(),
})
.strict()
.parse(data);
const updatedCredential = await prisma.credential.update({
where: { id: this.id },
data: pData,
});
this._updateInternalValues(updatedCredential);
return this;
}
/**
* Get Credential Type
*
* Returns the credential type information.
*
* @returns {CredentialType} - The credential type
*/
getType(): CredentialType {
return this._type;
}
/**
* Get Company
*
* Returns the company this credential belongs to.
*
* @returns {Company} - The company
*/
getCompany(): Company {
return this._company;
}
/**
* To JSON
*
* Create an object that can be safely returned to the user.
* Secure values are not included by default.
*
* @param opts - Options to change the output
* @returns - An object that is JSON friendly
*/
toJson(opts?: { includeSecureValues?: boolean }) {
return {
id: this.id,
name: this.name,
typeId: this.typeId,
companyId: this.companyId,
fields: this.fields,
type: {
id: this._type.id,
name: this._type.name,
fields: this._type.fields,
permissionScope: this._type.permissionScope,
},
company: {
id: this._company.id,
name: this._company.name,
},
secureFieldIds: opts?.includeSecureValues
? this._secureValues.map((sv) => sv.name)
: undefined,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
};
}
}
+178
View File
@@ -0,0 +1,178 @@
import { z } from "zod";
import { CredentialType, Credential } from "../../generated/prisma/client";
import { prisma } from "../constants";
import { CredentialTypeField } from "../modules/credentials/credentialTypeDefs";
import { CredentialController } from "./CredentialController";
/**
* Credential Type Controller
*
* This class manages credential type data, including field definitions,
* permission scopes, and associated credentials.
*/
export class CredentialTypeController {
public readonly id: string;
public name: string;
public permissionScope: string;
public icon: string | null;
public fields: CredentialTypeField[];
private _credentials: Credential[];
public readonly createdAt: Date;
public updatedAt: Date;
constructor(
credentialTypeData: CredentialType & {
credentials: Credential[];
},
) {
this.id = credentialTypeData.id;
this.name = credentialTypeData.name;
this.permissionScope = credentialTypeData.permissionScope;
this.icon = credentialTypeData.icon;
this.fields = credentialTypeData.fields! as any as CredentialTypeField[];
this._credentials = credentialTypeData.credentials;
this.createdAt = credentialTypeData.createdAt;
this.updatedAt = credentialTypeData.updatedAt;
}
/**
* Update Internal Values
*
* Internal method to update all internal values when we query the database.
* This keeps everything up-to-date even when we pass around the credential type controller.
*
* @param credentialTypeData - CredentialType object from Prisma
*/
private _updateInternalValues(credentialTypeData: CredentialType) {
this.name = credentialTypeData.name;
this.permissionScope = credentialTypeData.permissionScope;
this.icon = credentialTypeData.icon;
this.fields = credentialTypeData.fields! as any as CredentialTypeField[];
this.updatedAt = credentialTypeData.updatedAt;
}
/**
* Update Credential Type
*
* Update the credential type's name, permission scope, icon, or fields.
*
* @param data - Partial credential type data to update
* @returns {Promise<CredentialTypeController>} - The updated credential type controller
*/
async update(
data: Partial<
Pick<CredentialType, "name" | "permissionScope" | "icon"> & {
fields: CredentialTypeField[];
}
>,
): Promise<CredentialTypeController> {
const pData = z
.object({
name: z.string().optional(),
permissionScope: z.string().optional(),
icon: z.string().optional(),
fields: z.array(z.any()).optional(),
})
.strict()
.parse(data);
const updatedCredentialType = await prisma.credentialType.update({
where: { id: this.id },
data: pData,
});
this._updateInternalValues(updatedCredentialType);
return this;
}
/**
* Get Field Definition
*
* Get the definition for a specific field by its ID.
*
* @param fieldId - The field ID to look up
* @returns {CredentialTypeField | undefined} - The field definition or undefined
*/
getFieldDefinition(fieldId: string): CredentialTypeField | undefined {
return this.fields.find((f) => f.id === fieldId);
}
/**
* Get Required Fields
*
* Returns all fields that are marked as required.
*
* @returns {CredentialTypeField[]} - Array of required fields
*/
getRequiredFields(): CredentialTypeField[] {
return this.fields.filter((f) => f.required);
}
/**
* Get Secure Fields
*
* Returns all fields that should be stored securely (encrypted).
*
* @returns {CredentialTypeField[]} - Array of secure fields
*/
getSecureFields(): CredentialTypeField[] {
return this.fields.filter((f) => f.secure);
}
/**
* Fetch Credentials
*
* Fetch all credentials that use this credential type.
*
* @returns {Promise<CredentialController[]>} - Array of credential controllers
*/
async fetchCredentials(): Promise<CredentialController[]> {
const credentials = await prisma.credential.findMany({
where: { typeId: this.id },
include: {
type: true,
company: true,
securevalues: true,
},
});
return credentials.map((cred) => new CredentialController(cred));
}
/**
* Count Credentials
*
* Count how many credentials use this credential type.
*
* @returns {number} - Number of credentials using this type
*/
countCredentials(): number {
return this._credentials.length;
}
/**
* To JSON
*
* Create an object that can be safely returned to the user.
*
* @param opts - Options to change the output
* @returns - An object that is JSON friendly
*/
toJson(opts?: { includeCredentialCount?: boolean }) {
return {
id: this.id,
name: this.name,
permissionScope: this.permissionScope,
icon: this.icon,
fields: this.fields,
credentialCount: opts?.includeCredentialCount
? this._credentials.length
: undefined,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
};
}
}
+111
View File
@@ -0,0 +1,111 @@
import { prisma } from "../constants";
import { CredentialTypeController } from "../controllers/CredentialTypeController";
import { CredentialTypeField } from "../modules/credentials/credentialTypeDefs";
import GenericError from "../Errors/GenericError";
export const credentialTypes = {
/**
* Fetch Credential Type
*
* Fetch a credential type by its ID or name and return a CredentialTypeController instance.
*
* @param identifier - The credential type ID or name to fetch
* @returns {Promise<CredentialTypeController>} - The credential type controller
*/
async fetch(identifier: string): Promise<CredentialTypeController> {
const credentialType = await prisma.credentialType.findFirst({
where: {
OR: [{ id: identifier }, { name: identifier }],
},
include: {
credentials: true,
},
});
if (!credentialType) {
throw new GenericError({
message: "Credential type not found",
name: "CredentialTypeNotFound",
cause: `No credential type exists with identifier '${identifier}'`,
status: 404,
});
}
return new CredentialTypeController(credentialType);
},
/**
* Fetch All Credential Types
*
* Fetch all credential types in the system.
*
* @returns {Promise<CredentialTypeController[]>} - Array of credential type controllers
*/
async fetchAll(): Promise<CredentialTypeController[]> {
const credentialTypesList = await prisma.credentialType.findMany({
include: {
credentials: true,
},
});
return credentialTypesList.map((ct) => new CredentialTypeController(ct));
},
/**
* Create Credential Type
*
* Create a new credential type with its field definitions.
*
* @param data - The credential type data to create
* @returns {Promise<CredentialTypeController>} - The created credential type controller
*/
async create(data: {
name: string;
permissionScope: string;
fields: CredentialTypeField[];
icon?: string;
}): Promise<CredentialTypeController> {
// Check if a credential type with this name already exists
const existing = await prisma.credentialType.findFirst({
where: { name: data.name },
});
if (existing) {
throw new GenericError({
message: "Credential type name already exists",
name: "CredentialTypeAlreadyExists",
cause: `A credential type with name '${data.name}' already exists`,
status: 400,
});
}
const credentialType = await prisma.credentialType.create({
data: {
name: data.name,
permissionScope: data.permissionScope,
fields: data.fields as any,
icon: data.icon,
},
include: {
credentials: true,
},
});
return new CredentialTypeController(credentialType);
},
/**
* Delete Credential Type
*
* Delete a credential type by its ID.
* This will cascade delete all credentials of this type.
*
* @param id - The credential type ID to delete
* @returns {Promise<void>}
*/
async delete(id: string): Promise<void> {
await prisma.credentialType.delete({
where: { id },
});
},
};
+158
View File
@@ -0,0 +1,158 @@
import { prisma } from "../constants";
import { CredentialController } from "../controllers/CredentialController";
import { fieldValidator } from "../modules/credentials/fieldValidator";
import {
CredentialField,
CredentialTypeField,
} from "../modules/credentials/credentialTypeDefs";
import { generateSecureValue } from "../modules/credentials/generateSecureValue";
import GenericError from "../Errors/GenericError";
export const credentials = {
/**
* Fetch Credential
*
* Fetch a credential by its ID and return a CredentialController instance.
*
* @param id - The credential ID to fetch
* @returns {Promise<CredentialController>} - The credential controller
*/
async fetch(id: string): Promise<CredentialController> {
const credential = await prisma.credential.findFirst({
where: { id },
include: {
type: true,
company: true,
securevalues: true,
},
});
if (!credential) {
throw new GenericError({
message: "Credential not found",
name: "CredentialNotFound",
cause: `No credential exists with ID '${id}'`,
status: 404,
});
}
return new CredentialController(credential);
},
/**
* Fetch Credentials by Company
*
* Fetch all credentials associated with a specific company.
*
* @param companyId - The company ID to fetch credentials for
* @returns {Promise<CredentialController[]>} - Array of credential controllers
*/
async fetchByCompany(companyId: string): Promise<CredentialController[]> {
const credentialsList = await prisma.credential.findMany({
where: { companyId },
include: {
type: true,
company: true,
securevalues: true,
},
});
return credentialsList.map((cred) => new CredentialController(cred));
},
/**
* Create Credential
*
* Create a new credential with validated fields.
* This method processes all incoming field values, validating them against
* the credential type, encrypting secure fields, and inserting everything
* into the database atomically.
*
* @param data - The credential data to create
* @returns {Promise<CredentialController>} - The created credential controller
*/
async create(data: {
name: string;
typeId: string;
companyId: string;
fields: CredentialField[];
}): Promise<CredentialController> {
// Fetch the credential type to get acceptable fields
const credentialType = await prisma.credentialType.findFirst({
where: { id: data.typeId },
});
if (!credentialType) {
throw new GenericError({
message: "Credential type not found",
name: "CredentialTypeNotFound",
cause: `No credential type exists with ID '${data.typeId}'`,
status: 404,
});
}
// Validate the fields against acceptable fields
const acceptableFields = JSON.parse(credentialType.fields! as string).map(
(f: { id: string; name: string; secure: boolean }) => ({
id: f.id,
name: f.name,
secure: f.secure,
}),
) as CredentialTypeField[];
const validatedFields = await fieldValidator(data.fields, acceptableFields);
// Separate secure and non-secure fields
const secureFields = validatedFields.filter((f) => f.secure);
const nonSecureFields = validatedFields.filter((f) => !f.secure);
// Build fields object for non-secure fields
const fieldsObject: Record<string, any> = {};
nonSecureFields.forEach((field) => {
fieldsObject[field.fieldId] = field.value;
});
// Encrypt secure field values
const secureValueData = secureFields.map((field) => {
const { encrypted, hash } = generateSecureValue(field.value);
return {
name: field.fieldId,
content: encrypted,
hash,
};
});
// Create credential and all secure values in a transaction
const credential = await prisma.credential.create({
data: {
name: data.name,
typeId: data.typeId,
companyId: data.companyId,
fields: fieldsObject,
securevalues: {
create: secureValueData,
},
},
include: {
type: true,
company: true,
securevalues: true,
},
});
return new CredentialController(credential);
},
/**
* Delete Credential
*
* Delete a credential by its ID.
*
* @param id - The credential ID to delete
* @returns {Promise<void>}
*/
async delete(id: string): Promise<void> {
await prisma.credential.delete({
where: { id },
});
},
};
@@ -0,0 +1,18 @@
export enum ValueType {
PLAIN_TEXT = "plain_text",
PASSWORD = "password",
}
export interface CredentialTypeField {
id: string; // I.e. "clientId", "clientSecret", etc.
name: string; // I.e. "Client ID", "Client Secret", etc.
required: boolean;
secure: boolean; // Whether this field should be stored encrypted in the database
valueType: ValueType; // For future extensibility, currently all fields are strings
}
export interface CredentialField {
id: string; // CUID
fieldId: string; // I.e. "clientId", "clientSecret", etc.
value: string; // Encrypted value stored in the database
}
+56
View File
@@ -0,0 +1,56 @@
import { Collection } from "@discordjs/collection";
import { CredentialField, CredentialTypeField } from "./credentialTypeDefs";
import GenericError from "../../Errors/GenericError";
/**
* Field Validator
*
* This method will take a record of the fields being submitted and compare them against a record of the acceptable fields
* for a credential type. If any of the submitted fields do not match an acceptable field, an error will be thrown.
*
* If all the credentials pass, it will return a processed version of the submitted fields including fields that need to be
* stored securely (encrypted) and fields that do not.
*
* @param fields - The fields in object form that are being submitted.
* @param acceptableFields - The acceptable field to be compared against.
*/
export const fieldValidator = async (
fields: CredentialField[],
acceptableFields: CredentialTypeField[],
): Promise<
{
id: string;
fieldId: string;
value: string;
secure: boolean;
}[]
> => {
const afCollection = new Collection(acceptableFields.map((f) => [f.id, f]));
await Promise.all(
fields.map(async (field) => {
const matchingField = afCollection.get(field.fieldId);
if (!matchingField) {
throw new GenericError({
message: `Invalid field ID: ${field.fieldId}`,
name: "InvalidCredentialField",
cause: `No acceptable field with ID '${field.fieldId}' found.`,
status: 400,
});
}
return;
}),
);
return fields.map((field) => {
const matchingField = afCollection.get(field.fieldId)!;
return {
id: field.id,
fieldId: field.fieldId,
value: field.value,
secure: matchingField.secure,
};
});
};
@@ -0,0 +1,24 @@
import Password from "../tools/Password";
import crypto from "crypto";
import { secureValuesPublicKey } from "../../constants";
export const generateSecureValue = (content: string) => {
// Generate a hash of the content
const hash = Password.hash(content);
// Encrypt the content using the .secureValues.pub public key
const encrypted = crypto.publicEncrypt(
{
key: secureValuesPublicKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: "sha256",
},
Buffer.from(content, "utf-8"),
);
// Return the encrypted content and the hash for storage
return {
encrypted: encrypted.toString("base64"),
hash,
};
};
@@ -0,0 +1,30 @@
import Password from "../tools/Password";
import crypto from "crypto";
import { secureValuesPrivateKey } from "../../constants";
export const readSecureValue = (
encryptedContent: string,
hash?: string,
): string => {
// Decrypt the content using the .secureValues.key private key
const decrypted = crypto.privateDecrypt(
{
key: secureValuesPrivateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: "sha256",
},
Buffer.from(encryptedContent, "base64"),
);
const content = decrypted.toString("utf-8");
// Optionally validate the hash if provided
if (hash) {
const isValid = Password.validate(content, hash);
if (!isValid) {
throw new Error("Secure value hash validation failed");
}
}
return content;
};
+5 -2
View File
@@ -10,7 +10,10 @@ export default class Password {
public static hash(password: string, options?: HashPasswordOptions): string {
const salt =
options?.overrideSalt ?? Password.generateSalt(options?.saltOpts);
options?.overrideSalt ??
(options?.saltOpts?.length
? Password.generateSalt(options?.saltOpts)
: "");
const hash = blake2sHex(`$BLAKE2s$${password}$${salt}`);
return `BLAKE2s$${hash}$${salt}`;
}
@@ -19,7 +22,7 @@ export default class Password {
const [algo, oldHash, salt] = hashed.split(/\$/g);
return crypto.timingSafeEqual(
Buffer.from(hashed),
Buffer.from(Password.hash(newPass, { overrideSalt: salt }))
Buffer.from(Password.hash(newPass, { overrideSalt: salt })),
);
}
}