[INIT] Initial Project Structure
Some checks failed
Deploy Edge / deploy-edge (push) Failing after 2s
S3 Repo Backup / s3-repo-backup (push) Failing after 2s

This commit is contained in:
Dunemask 2024-08-24 12:41:04 -06:00
commit 0fc5f05b6a
105 changed files with 10448 additions and 0 deletions

32
lib/Cairo.ts Normal file
View file

@ -0,0 +1,32 @@
// Import Core Modules
import { INFO, OK, logInfo } from "@dunemask/vix/logging";
import { Vixpress } from "@dunemask/vix";
import { MappedRoute, routePreviews } from "@dunemask/vix/express";
import figlet from "figlet";
import AppRouter from "./vix/AppRouter.js";
import PostgresService from "./database/PostgresService.js";
import AppInitService from "./services/app-init.service.js";
export default class Cairo extends Vixpress {
static PostgresService: string = "pg";
static AppInitService: string = "app-init";
constructor(port?: number) {
super("Cairo", port ?? Number(process.env.CAIRO_DEV_PORT ?? 52000));
this.setService(Cairo.PostgresService, PostgresService);
this.setService(Cairo.AppInitService, AppInitService);
this.setRouter(AppRouter);
}
protected async preconfigure(): Promise<void> {
logInfo(figlet.textSync(this.title, "Cosmike"));
}
protected async onStart() {
const previews = routePreviews(this.app, (r: MappedRoute, methodDisplay: string) => {
const authSection = r.routeMetadata?.authType === "user" ? "🔒" : " ";
return `${methodDisplay} ${authSection} ${r.path}`;
});
for (const p of previews) INFO("ROUTE", p);
OK("SERVER", `${this.title} server running on ${this.port} 🚀`);
}
}

7
lib/app.ts Normal file
View file

@ -0,0 +1,7 @@
import "dotenv/config";
import "dotenv-expand/config";
import Cairo from "./Cairo";
import { assertRequired } from "./config";
assertRequired();
const cairo = new Cairo();
await cairo.start();

49
lib/config.ts Normal file
View file

@ -0,0 +1,49 @@
import DefaultRolePolicies from "./vix/AppPolicies";
const requiredEnvars: string[] = ["CAIRO_KEYPAIR_KEY"];
const encodedEnvar = (envar: string | undefined) => (!!envar ? Buffer.from(envar, "base64").toString("utf8") : envar);
export function assertRequired() {
for (const e of requiredEnvars) if (!process.env[e]) throw Error(`Envar '${e}' is required!`);
}
export default {
Server: {
basePath: "/cairo/",
projectSlug: "$cairo",
projectName: "$cairo",
rootPassword: process.env.CAIRO_ROOT_PASSWORD,
},
RolePolicy: {
Root: {
id: "ck1ro7ekp000203zu5gn3d9cr",
name: "Root",
policies: DefaultRolePolicies.Root,
},
Admin: {
id: "ck1ro7bm0000103z5h45sswqs",
name: "Admin",
policies: DefaultRolePolicies.Admin,
},
User: {
id: "ck1ro7g3e000303z52ee63nqs",
name: "User",
policies: DefaultRolePolicies.User,
},
},
SigningOptions: {
HashRounds: 12,
Version: "0.0.1-alpha",
Issuer: encodedEnvar(process.env.CAIRO_HOSTNAME) ?? "https://cairo.dunemask.net",
Keys: {
KeyPair: encodedEnvar(process.env.CAIRO_KEYPAIR_KEY) ?? "keypair-key",
},
Subjects: {
User: "user",
Cargo: "cargo",
Runner: "runner",
Pod: "pod",
},
},
};

View file

@ -0,0 +1,54 @@
import { Prisma, PrismaClient } from "@prisma/client";
import { VixpressService } from "@dunemask/vix";
import { VERB } from "@dunemask/vix/logging";
import UsersTableService from "./tables/UsersTableService.js";
import RolePolicyTableService from "./tables/RolePolicyTableService.js";
import ProjectTableService from "./tables/ProjectTableService.js";
import KeyPairTableService from "./tables/KeyPairTableService.js";
export class DBPrismaClient extends PrismaClient {
private async queryUniqueOrThrow<T = unknown>(data: unknown): Promise<T | undefined> {
if (!Array.isArray(data)) throw Error("Returned non-array!");
if (data.length > 1) throw Error("Non unique value found!");
return data.length === 1 ? data[0] : undefined;
}
async $queryRawUnique<T = unknown>(query: TemplateStringsArray | Prisma.Sql, ...values: any[]): Promise<T | undefined> {
const data = await this.$queryRaw(query, ...values);
return this.queryUniqueOrThrow<T>(data);
}
async $queryRawUnsafeUnique<T = unknown>(query: string, ...values: any[]): Promise<T | undefined> {
const data = await this.$queryRawUnsafe(query, ...values);
return this.queryUniqueOrThrow<T>(data);
}
}
export default class PostgresService extends VixpressService {
declare pg: DBPrismaClient;
declare users: UsersTableService;
declare rolePolicy: RolePolicyTableService;
declare project: ProjectTableService;
declare keypair: KeyPairTableService;
async configureService() {
this.pg = new DBPrismaClient({
errorFormat: "pretty",
log: ["warn", "error", "info", { emit: "event", level: "query" }],
});
this.users = new UsersTableService(this.pg);
this.rolePolicy = new RolePolicyTableService(this.pg);
this.project = new ProjectTableService(this.pg);
this.keypair = new KeyPairTableService(this.pg);
}
async startService() {
VERB("POSTGRES", "Connecting to postgres....");
await this.pg.$connect();
await this.project.$upsertDefaultProject();
await this.keypair.$upsertDefaultKeyPairs();
await this.rolePolicy.$upsertDefaultAuthorities();
const user = await this.users.$upsertDefaultRootUser();
if (!!user) VERB("APP INIT", `Created identity 'root' with password '${user.password}'!`);
if (!!user) VERB("APP INIT", "This will not be shown again!");
}
}

View file

@ -0,0 +1,9 @@
import { DBPrismaClient } from "./PostgresService";
export default abstract class TableService {
declare pg: DBPrismaClient;
protected abstract table: string;
constructor(pg: DBPrismaClient) {
this.pg = pg;
}
}

View file

@ -0,0 +1,60 @@
import config from "@lib/config";
import TableService from "../TableService";
import { CKeyPairContract } from "@lib/contracts/keypair.contracts";
import { KeyPairErrors, ProjectErrors } from "@lib/vix/ClientErrors";
import { encrypt, generateKeypair } from "@lib/svc/crypt.service";
import { KeyPair, KeyPairType } from "@prisma/client";
declare type Custom = "Custom"; // Make sure this matches KeyPairType.Custom;
export default class KeyPairTableService extends TableService {
protected table = "KeyPair";
async byId(keypairId: string) {
const keypair = this.pg.keyPair.findUnique({ where: { id: keypairId } });
if (!keypair) throw KeyPairErrors.NotFoundKeypair;
}
async byUsage(projectIdentity: string, usage: Custom): Promise<KeyPair[]>;
async byUsage(projectIdentity: string, usage: Exclude<KeyPairType, Custom>): Promise<KeyPair | null>;
async byUsage(projectIdentity: string, usage: KeyPairType): Promise<KeyPair[] | KeyPair | null> {
const projectOr = { OR: [{ id: projectIdentity }, { slug: projectIdentity }] };
const projectInclude = { keyPairs: { where: { usage: KeyPairType.UserToken } } };
const project = await this.pg.project.findFirst({ where: projectOr, include: projectInclude });
if (!project) throw ProjectErrors.BadRequestProjectIncomplete;
const keypairs = project.keyPairs;
if (usage !== KeyPairType.Custom && keypairs.length > 1)
throw new Error(`Multiple keypairs found for project ${projectIdentity} and usage ${usage}`);
if (usage !== KeyPairType.Custom && keypairs.length === 0) return null;
if (usage !== KeyPairType.Custom) return keypairs[0];
return keypairs;
}
async $upsertDefaultKeyPairs() {
const projectSlug = config.Server.projectSlug;
const cairoProject = await this.pg.project.findUnique({ where: { slug: projectSlug } });
if (!cairoProject) throw new Error("Cairo Project Not Found!");
const projectId = cairoProject.id;
await this.upsertProjecttDefaultKeyPairs(projectId);
}
async upsertProjecttDefaultKeyPairs(projectId: string) {
const storeKeypair = this.create.bind(this);
const keyTypes = Object.values(KeyPairType).filter((kp) => kp !== KeyPairType.Custom);
await Promise.all(
keyTypes.map(async (kp) => {
const existingKp = await this.byUsage(projectId, kp);
if (!!existingKp) return;
const { publicKey, privateKey } = await generateKeypair();
const [encryptedPrivateKey, encryptedPublicKey] = await Promise.all([
encrypt(privateKey, config.SigningOptions.Keys.KeyPair),
encrypt(publicKey, config.SigningOptions.Keys.KeyPair),
]);
return storeKeypair({ encryptedPrivateKey, encryptedPublicKey, projectId, usage: kp });
}),
);
}
async create(keypair: CKeyPairContract["Create"]) {
return this.pg.keyPair.create({ data: keypair });
}
}

View file

@ -0,0 +1,34 @@
import config from "@lib/config";
import TableService from "../TableService";
import { CProjectContract } from "@lib/types/ContractTypes";
import { ProjectErrors } from "@lib/vix/ClientErrors";
import { VERB } from "@dunemask/vix/logging";
export default class ProjectTableService extends TableService {
protected table = "Project";
async byId(projectId: string) {
return this.pg.project.findUnique({ where: { id: projectId } });
}
async bySlug(slug: string) {
return this.pg.project.findUnique({ where: { slug } });
}
async $upsertDefaultProject() {
const { projectSlug: slug, projectName: name } = config.Server;
const createOptions = { slug, name, parentProject: slug };
const existingProject = await this.pg.project.findUnique({ where: { slug } });
if (!!existingProject) return VERB("PROJECT", "Default project already exists!");
VERB("PROJECT", "Default project not found! Creating now!");
const proj = await this.pg.project.upsert({ where: { slug }, create: createOptions, update: createOptions });
await this.pg.project.update({ where: { id: proj.id }, data: { parentProject: proj.id } }); // Use ProjectID instead of slug
}
async create(project: CProjectContract["Create"] & { parentProject: string }) {
const existingProject = await this.pg.project.findMany({ where: { id: project.slug } });
if (existingProject.length > 1) throw ProjectErrors.BadRequestSlugInvalid;
return this.pg.project.create({ data: project }).catch(() => {
throw ProjectErrors.ConflictNonUnique;
});
}
}

View file

@ -0,0 +1,37 @@
import TableService from "../TableService";
import { AuthorityType } from "@prisma/client";
import { Policy, PolicyDefault } from "@lib/Policies";
import config from "@lib/config";
import { CRolePolicyContract } from "@lib/contracts/role-policy.contracts";
export default class RolePolicyTableService extends TableService {
protected table = "RolePolicy";
async byId(id: string) {
return this.pg.rolePolicy.findUnique({ where: { id } });
}
async $upsertDefaultAuthorities() {
const projectSlug = config.Server.projectSlug;
const cairoProject = await this.pg.project.findUnique({ where: { slug: projectSlug } });
if (!cairoProject) throw new Error("Cairo Project Not Found!");
const project = cairoProject.id;
const $chk = ({ id, name, policies }: PolicyDefault) => this.$upsertDefaultsAuthority(project, name, id, policies);
await Promise.all(Object.values(config.RolePolicy).map($chk));
}
private async $upsertDefaultsAuthority(projectId: string, name: string, id: string, userPolicies: Policy[]) {
const rootAuthority = config.RolePolicy.Root.id;
const authorityType = id === rootAuthority ? AuthorityType.Root : AuthorityType.RolePolicy;
const authority = id === rootAuthority ? name : rootAuthority; // Set Root Authority to root if root
const policies = Policy.asStrings(userPolicies);
return this.pg.rolePolicy.upsert({
where: { id },
create: { projectId, id, name, policies, authority, authorityType },
update: { projectId, name, policies, authority, authorityType },
});
}
async create(rp: CRolePolicyContract["Create"]) {
return this.pg.rolePolicy.create({ data: rp });
}
}

View file

@ -0,0 +1,63 @@
import config from "@lib/config";
import TableService from "../TableService";
import { CUserContract } from "@lib/types/ContractTypes";
import { hashText } from "@lib/modules/auth/auth.service";
import { KeyPairType } from "@prisma/client";
import { UserErrors } from "@lib/vix/ClientErrors";
// prettier-ignore
const generateBase64Password = (length: number = 32): string => Array.from(crypto.getRandomValues(new Uint8Array(length)), byte => 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.charAt(byte % 64)).join('');
export default class UsersTableService extends TableService {
protected table = "User";
async byId(userId: string) {
return this.pg.user.findUnique({ where: { id: userId }, include: { rolePolicy: true, project: true } });
}
async byUsername(username: string, projectId: string) {
return this.pg.user.findUnique({ where: { projectId_username: { projectId, username } } });
}
async byEmail(email: string, projectId: string) {
return this.pg.user.findUnique({ where: { projectId_email: { projectId, email } } });
}
async $upsertDefaultRootUser() {
const project = await this.pg.project.findUnique({ where: { slug: config.Server.projectSlug } });
if (!project) throw new Error("Cairo Project Not Found!");
const rolePolicyId = config.RolePolicy.Root.id;
return this.$upsertRootUser(project.id, rolePolicyId);
}
async $upsertRootUser(projectId: string, rolePolicyId: string) {
const root = await this.pg.user.findUnique({ where: { projectId_username: { username: "root", projectId } } });
if (!!root) return;
const password = config.Server.rootPassword ?? generateBase64Password();
const hash = await hashText(password);
const user = await this.pg.user.create({ data: { projectId, username: "root", email: "root", hash, rolePolicyId } });
return { ...user, password };
}
async create(options: CUserContract["Create"]) {
const { hash, projectId, rolePolicyId } = options;
const username = options.username?.toLowerCase();
const email = options.email?.toLowerCase() ?? undefined;
const [existingUsername, existingEmail] = await Promise.all([
this.byUsername(username, projectId),
!!email ? this.byEmail(email, projectId) : undefined,
]);
if (!existingUsername || !existingEmail) throw UserErrors.ConflictIdentityTaken;
const userData = { projectId, username, email, hash, rolePolicyId };
return this.pg.user.create({ data: userData, include: { rolePolicy: true } });
}
async byIdentity(projectIdentity: string, identity: string) {
const username = identity.toLowerCase();
const email = identity.toLowerCase();
const OrUser = { OR: [{ username }, { email }] };
const OrProject = { project: { OR: [{ id: projectIdentity }, { slug: projectIdentity }] } };
const projectInclude = { include: { keyPairs: { where: { usage: KeyPairType.UserToken } } } };
const AND = [OrUser, OrProject];
return this.pg.user.findFirst({ where: { AND }, include: { rolePolicy: true, project: projectInclude } });
}
}

View file

@ -0,0 +1,22 @@
import { Request, Response, NextFunction, Router, Express } from "express";
import userGuard from "./user-guard";
import { MetadataRouter } from "@dunemask/vix/express";
import { Policy } from "@lib/Policies";
import { AuthErrors } from "@lib/vix/ClientErrors";
import { UserRequest } from "@lib/types/ApiRequests";
export default function policyMiddlewareGuard(requiredPolicies: Policy[]) {
const middlewares: MetadataRouter = Router({ mergeParams: true });
async function policyAuthMiddleware(req: Request, res: Response, next: NextFunction) {
const { user, policies: userPolicies } = req as UserRequest;
if (!user) throw AuthErrors.UnauthorizedRequest;
if (!userPolicies) throw AuthErrors.UnauthorizedRequest;
if (!Policy.multiAuthorizedTo(userPolicies, requiredPolicies)) throw AuthErrors.ForbiddenPermissions;
if (!next) return res.sendStatus(200);
next();
}
middlewares.routeMetadata = { authType: "policy" };
middlewares.use([userGuard(), policyAuthMiddleware]);
return middlewares;
}

View file

@ -0,0 +1,41 @@
import { AuthorizedTokenRequest, MetadataRouter, tokenAuthMiddleware } from "@dunemask/vix/express";
import Cairo from "@lib/Cairo";
import { getUserTokenId } from "@lib/modules/auth/auth.service";
import { Policy, PolicyComputeType } from "@lib/Policies";
import { UserRequest } from "@lib/types/ApiRequests";
import { Resource } from "@lib/vix/AppResources";
import { AuthErrors, ProjectErrors } from "@lib/vix/ClientErrors";
import { Request, Response, NextFunction, Router, Express } from "express";
import expressBearerToken from "express-bearer-token";
import type PostgresService from "@lib/database/PostgresService.js";
import { KeyPairType, User } from "@prisma/client";
export default function userGuard() {
const middlewares: MetadataRouter = Router({ mergeParams: true });
async function userGuardMiddleware(req: Request, _res: Response, next: NextFunction) {
const { token } = req as AuthorizedTokenRequest;
if (!token) throw AuthErrors.UnauthorizedRequiredToken;
const PostgresService = req.app.get(Cairo.PostgresService) as PostgresService;
const { project } = req.params;
if (!project) throw AuthErrors.UnauthorizedRequiredProject;
const userKeypair = await PostgresService.keypair.byUsage(project, KeyPairType.UserToken);
if (!userKeypair) throw ProjectErrors.BadRequestProjectIncomplete;
const id = await getUserTokenId(token, userKeypair.encryptedPublicKey);
if (!id) throw AuthErrors.UnauthorizedRequest;
const user = await PostgresService.users.byId(id);
if (!user) throw AuthErrors.UnauthorizedRequiredUser;
const policies = Policy.parseResourcePolicies<Resource>(user.rolePolicy.policies as PolicyComputeType);
const projectData = { ...user.project };
delete (user as Partial<typeof user>).project;
(req as UserRequest).user = user;
(req as UserRequest).policies = policies;
(req as UserRequest).project = projectData;
next();
}
middlewares.routeMetadata = { authType: "user" };
middlewares.use([expressBearerToken(), userGuardMiddleware]);
return middlewares;
}

View file

@ -0,0 +1,48 @@
import { Request, Response, Express } from "express";
import { VixpressController } from "@dunemask/vix";
import Cairo from "@lib/Cairo";
import type PostgresService from "@lib/database/PostgresService";
import { CAuthContract, AuthContract } from "@lib/contracts/auth.contracts";
import { ContractRouteContext } from "@dunemask/vix/express";
import { AuthErrors, ProjectErrors } from "@lib/vix/ClientErrors";
import { getUserToken, hashCompare } from "./auth.service";
import { CDatabaseContract } from "@lib/contracts/database.contracts";
import { ProjectContract } from "@lib/contracts/project.contracts";
import { UserRequest } from "@lib/types/ApiRequests";
import { ResourcePolicy } from "@dunemask/vix/util";
type LoginCRC = ContractRouteContext<{
RequestBodyContract: typeof AuthContract.Login;
RequestParamsContract: typeof ProjectContract.ProjectParams;
}>;
export default class AuthController extends VixpressController {
declare pg: PostgresService;
constructor(app: Express) {
super(app);
this.pg = this.app.get(Cairo.PostgresService);
}
verify = (_req: Request, res: Response) => res.sendStatus(200);
async login(crc: LoginCRC): Promise<CAuthContract["LoginCredentials"]> {
const { identity, password } = crc.reqBody;
const { project } = crc.reqParams;
const user = await this.pg.users.byIdentity(project, identity);
if (!user?.rolePolicy?.policies) throw AuthErrors.UnauthorizedRequest;
const authorized = await hashCompare(password, user.hash);
if (!authorized) throw AuthErrors.UnauthorizedRequest;
const projectKeyPairs = user.project.keyPairs;
if (projectKeyPairs.length !== 1) throw ProjectErrors.BadRequestProjectIncomplete;
const token = await getUserToken(user.id, user.project.keyPairs[0].encryptedPrivateKey);
const policies = user.rolePolicy.policies;
const userData: CDatabaseContract["User"] = { username: user.username, rolePolicyId: user.rolePolicyId };
return { token, user: userData, policies };
}
async credentials(crc: ContractRouteContext): Promise<CAuthContract["Credentials"]> {
const { user, policies } = crc.req as UserRequest;
const userData: CDatabaseContract["User"] = { username: user.username, rolePolicyId: user.rolePolicyId };
return { user: userData, policies: ResourcePolicy.asStrings(policies) };
}
}

View file

@ -0,0 +1,26 @@
import { VixpressRoute } from "@dunemask/vix";
import { contract } from "@dunemask/vix/express";
import RouteGuard from "@lib/vix/RouteGuard";
import { Router } from "express";
import AuthController from "./auth.controller";
import { AuthContract } from "@lib/contracts/auth.contracts";
import { ProjectContract } from "@lib/contracts/project.contracts";
export class AuthRoute extends VixpressRoute {
async configureRoutes(router: Router) {
const jsonOpts = { limit: "20mb" };
const cBase = { json: jsonOpts, reqParams: ProjectContract.ProjectParams };
// Controllers
const authController = this.useController(AuthController);
// Configuration
const loginCreds = { ...cBase, reqBody: AuthContract.Login, resBody: AuthContract.LoginCredentials };
const credRes = { ...cBase, resBody: AuthContract.Credentials };
// Middleware
// Routes
router.get("/verify", RouteGuard.User, authController.verify);
router.post("/login", contract(authController.login, loginCreds));
router.get("/credentials", RouteGuard.User, contract(authController.credentials, credRes));
}
}

View file

@ -0,0 +1,36 @@
import bcrypt from "bcrypt";
import { signToken, verifyToken } from "@lib/svc/token.service";
import config from "@lib/config";
import { decrypt } from "@lib/svc/crypt.service";
const { HashRounds } = config.SigningOptions;
export async function getUserToken(id: string, encryptedPrivateKey: string) {
const privateKey = await decrypt(encryptedPrivateKey, config.SigningOptions.Keys.KeyPair);
const tokenPayload = {
iss: config.SigningOptions.Issuer,
sub: [config.SigningOptions.Subjects.User],
aud: [config.SigningOptions.Issuer],
id,
};
return signToken(tokenPayload, privateKey);
}
export async function userTokenLogin(token: string, encryptedPublicKey: string): Promise<boolean> {
const publicKey = await decrypt(encryptedPublicKey, config.SigningOptions.Keys.KeyPair);
return !!verifyToken(token, publicKey);
}
export async function getUserTokenId(token: string, encryptedPublicKey: string): Promise<string | undefined> {
const publicKey = await decrypt(encryptedPublicKey, config.SigningOptions.Keys.KeyPair);
const tokenData = verifyToken(token, publicKey);
if (!tokenData) return undefined;
return tokenData.id;
}
export async function hashText(password: string) {
return bcrypt.hash(password, HashRounds);
}
export async function hashCompare(password: string, hash: string) {
return bcrypt.compare(password, hash);
}

View file

@ -0,0 +1,48 @@
import { Express } from "express";
import { VixpressController } from "@dunemask/vix";
import Cairo from "@lib/Cairo";
import type PostgresService from "@lib/database/PostgresService";
import { ContractRouteContext } from "@dunemask/vix/express";
import { ProjectErrors } from "@lib/vix/ClientErrors";
import { CDatabaseContract } from "@lib/contracts/database.contracts";
import { CProjectContract, ProjectContract } from "@lib/contracts/project.contracts";
import { KeyPairType } from "@prisma/client";
import { decrypt } from "@lib/svc/crypt.service";
import config from "@lib/config";
import { UserRequest } from "@lib/types/ApiRequests";
import { PolicyString } from "@lib/Policies";
import { Resource } from "@lib/vix/AppResources";
type CreateCRC = ContractRouteContext<{
RequestBodyContract: typeof ProjectContract.Create;
RequestParamsContract: typeof ProjectContract.ProjectParams;
}>;
export default class ProjectController extends VixpressController {
declare pg: PostgresService;
constructor(app: Express) {
super(app);
this.pg = this.app.get(Cairo.PostgresService);
}
async create(crc: CreateCRC): Promise<CProjectContract["CreateResponse"]> {
const { project: parentProject } = crc.req as UserRequest;
const proj = await this.pg.project.create({ ...crc.reqBody, parentProject: parentProject.id });
const rolePolicy = await this.pg.rolePolicy.create({
name: `${crc.reqBody.slug} Project Root`,
authority: config.RolePolicy.Root.id,
projectId: proj.id,
policies: [`${Resource.CairoProjectRoot}.root`] as PolicyString[],
});
const [user] = await Promise.all([
this.pg.users.$upsertRootUser(proj.id, rolePolicy.id),
this.pg.keypair.upsertProjecttDefaultKeyPairs(proj.id),
]);
const kp = await this.pg.keypair.byUsage(proj.id, KeyPairType.UserToken);
if (!kp) throw ProjectErrors.BadRequestProjectIncomplete;
if (!user) throw ProjectErrors.UnexpectedRootUserError;
const userData: CDatabaseContract["User"] = { username: user.username, rolePolicyId: user.rolePolicyId };
const publicKey = await decrypt(kp.encryptedPublicKey, config.SigningOptions.Keys.KeyPair);
return { user: userData, project: proj, publicKey };
}
}

View file

@ -0,0 +1,22 @@
import { VixpressRoute } from "@dunemask/vix";
import { contract } from "@dunemask/vix/express";
import { Router } from "express";
import ProjectController from "./project.controller";
import { ProjectContract } from "@lib/contracts/project.contracts";
import RouteGuard from "@lib/vix/RouteGuard";
export class ProjectRoute extends VixpressRoute {
async configureRoutes(router: Router) {
const jsonOpts = { limit: "20mb" };
const cBase = { json: jsonOpts, reqParams: ProjectContract.ProjectParams };
// Controllers
const projController = this.useController(ProjectController);
// Configuration
const projCreate = { ...cBase, reqBody: ProjectContract.Create };
// Middleware
// Routes
router.post("/create", RouteGuard.MangeProjectsCreate, contract(projController.create, projCreate));
}
}

View file

@ -0,0 +1,36 @@
import bcrypt from "bcrypt";
import { signToken, verifyToken } from "@lib/svc/token.service";
import config from "@lib/config";
import { decrypt } from "@lib/svc/crypt.service";
const { HashRounds } = config.SigningOptions;
export async function getUserToken(id: string, encryptedPrivateKey: string) {
const privateKey = await decrypt(encryptedPrivateKey, config.SigningOptions.Keys.KeyPair);
const tokenPayload = {
iss: config.SigningOptions.Issuer,
sub: [config.SigningOptions.Subjects.User],
aud: [config.SigningOptions.Issuer],
id,
};
return signToken(tokenPayload, privateKey);
}
export async function userTokenLogin(token: string, encryptedPublicKey: string): Promise<boolean> {
const publicKey = await decrypt(encryptedPublicKey, config.SigningOptions.Keys.KeyPair);
return !!verifyToken(token, publicKey);
}
export async function getUserTokenId(token: string, encryptedPublicKey: string): Promise<string | undefined> {
const publicKey = await decrypt(encryptedPublicKey, config.SigningOptions.Keys.KeyPair);
const tokenData = verifyToken(token, publicKey);
if (!tokenData) return undefined;
return tokenData.id;
}
export async function hashText(password: string) {
return bcrypt.hash(password, HashRounds);
}
export async function hashCompare(password: string, hash: string) {
return bcrypt.compare(password, hash);
}

View file

@ -0,0 +1,9 @@
import { VixpressService } from "@dunemask/vix";
import { OK, VERB } from "@dunemask/vix/logging";
export default class AppInitService extends VixpressService {
async startService() {
VERB("APP INIT", "Running init services....");
OK("APP INIT", "Done!");
}
}

View file

@ -0,0 +1,37 @@
import crypto, { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
export async function generateKeypair() {
return crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "pkcs1", format: "pem" },
privateKeyEncoding: { type: "pkcs1", format: "pem" },
});
}
export async function encrypt(plaintext: string, hexKey: string): Promise<string> {
const key = Buffer.from(hexKey, "hex");
const algorithm = "aes-256-cbc"; // Encryption algorithm
const iv = randomBytes(16); // Initialization vector
const cipher = createCipheriv(algorithm, key, iv);
let encrypted = cipher.update(plaintext, "utf8", "hex");
encrypted += cipher.final("hex");
// Combine IV and encrypted text
return iv.toString("hex") + ":" + encrypted;
}
// Decrypt function
export async function decrypt(encryptedText: string, hexKey: string): Promise<string> {
const key = Buffer.from(hexKey, "hex");
const algorithm = "aes-256-cbc";
const textParts = encryptedText.split(":");
const iv = Buffer.from(textParts[0], "hex");
const encrypted = textParts[1];
const decipher = createDecipheriv(algorithm, key, iv);
let decrypted = decipher.update(encrypted, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}

View file

@ -0,0 +1,15 @@
import jwt, { Secret, SignOptions } from "jsonwebtoken";
export function signToken(payload: object, signingKey: Secret, options: SignOptions = {}) {
return jwt.sign(payload, signingKey, {
...{ algorithm: "RS256" },
...options,
});
}
export function verifyToken(token: string, signingKey: Secret) {
return jwtVerify(token, signingKey) ?? undefined;
}
const jwtVerify = (token: string, key: Secret): any =>
jwt.verify(token, key, (err: any, decoded: any) => (!err && decoded) || null);

9
lib/types/ApiRequests.ts Normal file
View file

@ -0,0 +1,9 @@
import { Policy } from "@lib/Policies";
import { AuthorizedTokenRequest } from "@dunemask/vix/express";
import { Project, User } from "@prisma/client";
export interface UserRequest extends AuthorizedTokenRequest {
user: User;
policies: Policy[];
project: Project;
}

View file

@ -0,0 +1,5 @@
export type { CAuthContract } from "./contracts/auth.contracts";
export type { CDatabaseContract } from "./contracts/database.contracts";
export type { CUserContract } from "./contracts/user.contracts";
export type { CProjectContract } from "./contracts/project.contracts";
export type { CKeyPairContract } from "./contracts/keypair.contracts";

View file

@ -0,0 +1,40 @@
import { ContractTypeDefinitions, defineContractExport, defineContracts } from "@dunemask/vix/util";
import * as y from "yup";
import { DatabaseContractRes } from "./database.contracts";
// ======================================= Re-used Contracts -======================================
const Credentials = y.object({
user: DatabaseContractRes.User.required(),
policies: y.array(y.string()).required(),
});
// ====================================== Responses Contracts ======================================
export const AuthContractRes = defineContractExport("CAuthContractRes", {
Credentials,
LoginCredentials: y
.object({
token: y.string().required(),
})
.concat(Credentials),
});
// ======================================= Request Contracts =======================================
export const AuthContractReq = defineContractExport("CAuthContractReq", {
Login: y.object({
identity: y.string().required(),
password: y.string().required(),
}),
});
// ===================================== Combined Declarations =====================================
export const AuthContract = defineContractExport("CAuthContract", { ...AuthContractRes, ...AuthContractReq });
// ======================================= Type Declarations =======================================
export type CAuthContractRes = ContractTypeDefinitions<typeof AuthContractRes>;
export type CAuthContractReq = ContractTypeDefinitions<typeof AuthContractReq>;
export type CAuthContract = ContractTypeDefinitions<typeof AuthContract>;

View file

@ -0,0 +1,35 @@
import { ContractTypeDefinitions, defineContractExport, defineContracts } from "@dunemask/vix/util";
import * as y from "yup";
const antiRequired = y.string().test("insecure-exposure", "Insecure Exposure", (value) => !value);
// ====================================== Reused Contracts ======================================
// ====================================== Response Contracts ======================================
export const DatabaseContractRes = defineContractExport("CDatabaseContractRes", {
User: y.object({
username: y.string().required(),
email: y.string().nullable(),
hash: antiRequired,
rolePolicyId: y.string().required(),
}),
Project: y.object({
id: y.string().required(),
slug: y.string().required(),
parentProject: y.string().required(),
name: y.string().nullable(),
}),
});
// ====================================== Request Contracts ======================================
export const DatabaseContractReq = defineContractExport("CDatabaseContractReq", {});
// ====================================== Combined Declarations ======================================
export const DatabaseContract = defineContractExport("CDatabaseContract", { ...DatabaseContractRes, ...DatabaseContractReq });
// ====================================== Type Declarations ======================================
export type CDatabaseContractRes = ContractTypeDefinitions<typeof DatabaseContractRes>;
export type CDatabaseContractReq = ContractTypeDefinitions<typeof DatabaseContractReq>;
export type CDatabaseContract = ContractTypeDefinitions<typeof DatabaseContract>;

View file

@ -0,0 +1,31 @@
import { ContractTypeDefinitions, defineContractExport, defineContracts } from "@dunemask/vix/util";
import { KeyPairType } from "@prisma/client";
import * as y from "yup";
const antiRequired = y.string().test("insecure-exposure", "Insecure Exposure", (value) => !!value);
// ====================================== Reused Contracts ======================================
// ====================================== Response Contracts ======================================
export const KeyPairContractRes = defineContractExport("CKeyPairContractRes", {});
// ====================================== Request Contracts ======================================
export const KeyPairContractReq = defineContractExport("CKeyPairContractReq", {
Create: y.object({
projectId: y.string().required(),
usage: y.string().oneOf(Object.values(KeyPairType)).required(),
encryptedPublicKey: y.string().required(),
encryptedPrivateKey: y.string().required(),
name: y.string().nullable(),
}),
});
// ====================================== Combined Declarations ======================================
export const KeyPairContract = defineContractExport("CKeyPairContract", { ...KeyPairContractRes, ...KeyPairContractReq });
// ====================================== Type Declarations ======================================
export type CKeyPairContractRes = ContractTypeDefinitions<typeof KeyPairContractRes>;
export type CKeyPairContractReq = ContractTypeDefinitions<typeof KeyPairContractReq>;
export type CKeyPairContract = ContractTypeDefinitions<typeof KeyPairContract>;

View file

@ -0,0 +1,36 @@
import { ContractTypeDefinitions, defineContractExport, defineContracts } from "@dunemask/vix/util";
import * as y from "yup";
import { DatabaseContract } from "./database.contracts";
// ====================================== Reused Contracts ======================================
// ====================================== Response Contracts ======================================
export const ProjectContractRes = defineContractExport("CProjectContractRes", {
CreateResponse: y.object({
project: DatabaseContract.Project,
user: DatabaseContract.User,
publicKey: y.string().required(),
}),
});
// ====================================== Request Contracts ======================================
export const ProjectContractReq = defineContractExport("CProjectContractReq", {
ProjectParams: y.object({
project: y.string().required(),
}),
Create: y.object({
slug: y.string().required(),
name: y.string().nullable(),
}),
});
// ====================================== Combined Declarations ======================================
export const ProjectContract = defineContractExport("CProjectContract", { ...ProjectContractRes, ...ProjectContractReq });
// ====================================== Type Declarations ======================================
export type CProjectContractRes = ContractTypeDefinitions<typeof ProjectContractRes>;
export type CProjectContractReq = ContractTypeDefinitions<typeof ProjectContractReq>;
export type CProjectContract = ContractTypeDefinitions<typeof ProjectContract>;

View file

@ -0,0 +1,34 @@
import { ContractTypeDefinitions, defineContractExport, defineContracts } from "@dunemask/vix/util";
import { AuthorityType } from "@prisma/client";
import * as y from "yup";
const antiRequired = y.string().test("insecure-exposure", "Insecure Exposure", (value) => !value);
// ====================================== Reused Contracts ======================================
// ====================================== Response Contracts ======================================
export const RolePolicyContractRes = defineContractExport("CRolePolicyContractRes", {});
// ====================================== Request Contracts ======================================
export const RolePolicyContractReq = defineContractExport("CRolePolicyContractReq", {
Create: y.object({
authority: y.string().required(),
authorityType: y.string().oneOf(Object.values(AuthorityType)),
projectId: y.string().required(),
name: y.string().required(),
policies: y.array(y.string().required()).required(),
}),
});
// ====================================== Combined Declarations ======================================
export const RolePolicyContract = defineContractExport("CRolePolicyContract", {
...RolePolicyContractRes,
...RolePolicyContractReq,
});
// ====================================== Type Declarations ======================================
export type CRolePolicyContractRes = ContractTypeDefinitions<typeof RolePolicyContractRes>;
export type CRolePolicyContractReq = ContractTypeDefinitions<typeof RolePolicyContractReq>;
export type CRolePolicyContract = ContractTypeDefinitions<typeof RolePolicyContract>;

View file

@ -0,0 +1,29 @@
import { ContractTypeDefinitions, defineContractExport } from "@dunemask/vix/util";
import * as y from "yup";
// ====================================== Reused Contracts ======================================
// ====================================== Response Contracts ======================================
export const UserContractRes = defineContractExport("CUserContractRes", {});
// ====================================== Request Contracts ======================================
export const UserContractReq = defineContractExport("CUserContractReq", {
Create: y.object({
projectId: y.string().required(),
username: y.string().required(),
email: y.string(),
hash: y.string().required(),
rolePolicyId: y.string().required(),
}),
});
// ====================================== Combined Declarations ======================================
export const UserContract = defineContractExport("CUserContract", { ...UserContractRes, ...UserContractReq });
// ====================================== Type Declarations ======================================
export type CUserContractRes = ContractTypeDefinitions<typeof UserContractRes>;
export type CUserContractReq = ContractTypeDefinitions<typeof UserContractReq>;
export type CUserContract = ContractTypeDefinitions<typeof UserContract>;

16
lib/util/mailing.ts Normal file
View file

@ -0,0 +1,16 @@
import sgMail from "@sendgrid/mail";
const { CAIRO_BOT_EMAIL: botEmail, CAIRO_SENDGRID_KEY: sendgridApiKey } = process.env;
// Configure API Key
sgMail.setApiKey(sendgridApiKey ?? "");
if (!botEmail && !!sendgridApiKey) throw Error("Bot Email wasn't defined but API key was!");
const from = botEmail ?? "donotreply@dunemask.dev";
const ignoreMessage = `If you did not sign up for a cairo account, please ignore this email!`;
export const sendMessage = (to: string, subject: string, text: string) =>
sgMail.send({ from, to, subject, text: text + `\n${ignoreMessage}` });
export const sendHtml = (to: string, subject: string, html: string) =>
sgMail.send({ from, to, subject, html: html + `<p>${ignoreMessage}</p>` });

15
lib/vix/AppGuards.ts Normal file
View file

@ -0,0 +1,15 @@
import { IAMResource, ManagementResource } from "./AppResources";
import { Policy, PolicyType } from "./AppPolicies";
function appPolicies(...userPolicies: PolicyType[] | PolicyType[][]) {
const policies = userPolicies.length === 1 && Array.isArray(userPolicies[0]) ? userPolicies[0] : (userPolicies as PolicyType[]);
const requiredPolicies = Policy.parseResourcePolicies(policies);
return requiredPolicies;
}
export default class AppGuard {
static IAMRoot = appPolicies(`${IAMResource.Root}.root`);
static IAMAuthenticated = appPolicies(Object.values(IAMResource).map((iam) => `${iam}.root`) as PolicyType[]);
static ManageProjects = appPolicies(`${ManagementResource.ManageProject}.*`);
static CreateProjects = appPolicies(`${ManagementResource.ManageProject}.create`);
}

26
lib/vix/AppPolicies.ts Normal file
View file

@ -0,0 +1,26 @@
import { ResourcePolicy, ResourcePolicyComputeType, ResourcePolicyString, ResourcePolicyType } from "@dunemask/vix/util";
import { IAMResource, ManagementResource, Resource } from "./AppResources";
export const Policy = ResourcePolicy<Resource>;
export declare type Policy = ResourcePolicy<Resource>;
export declare type PolicyType = ResourcePolicyType<Resource>;
export declare type PolicyComputeType = ResourcePolicyComputeType<Resource>;
export declare type PolicyString = ResourcePolicyString<Resource>;
export declare type PolicyDefault = { id: string; name: string; policies: Policy[] };
export default class DefaultRolePolicies {
static Root = $unsafeGetRootPolicy();
static Admin = Policy.multiple<Resource>(
`${IAMResource.Admin}.root`,
`${ManagementResource.ManageProject}.root`,
`${ManagementResource.ManageUser}.root`,
);
static User = Policy.multiple<Resource>(`${IAMResource.User}.root`);
}
function $unsafeGetRootPolicy(): Policy[] {
const policies: PolicyString[] = [];
for (const resource of Object.values(Resource)) policies.push(`${resource}.root`);
return Policy.multiple<Resource>(...policies) as Policy[];
}

20
lib/vix/AppResources.ts Normal file
View file

@ -0,0 +1,20 @@
export enum IAMResource {
Root = "root",
Admin = "admin",
User = "user",
CairoProjectRoot = "cairo-project-root",
}
export enum ManagementResource {
ManageAdmin = "manage-admin",
ManageUser = "manage-user",
ManageProject = "manage-project",
}
export enum OtherResource {
Random = "Random",
}
type ResourceEnums<T extends Record<string, string>> = T[keyof T];
export const Resource = { ...IAMResource, ...ManagementResource, ...OtherResource };
export type Resource = ResourceEnums<typeof Resource>;

15
lib/vix/AppRouter.ts Normal file
View file

@ -0,0 +1,15 @@
import "express-async-errors";
import config from "@lib/config";
import { VixpressRouter } from "@dunemask/vix";
import { AuthRoute } from "@lib/modules/auth/auth.router";
import { ProjectRoute } from "@lib/modules/projects/project.router";
export default class AppRouter extends VixpressRouter {
protected routerImportUrl = import.meta.url; // Used to configure the relative static route
protected baseUrl = config.Server.basePath; // Path for static assets
async configureRoutes() {
// API Routes go here:
await this.useRoute("/api/:project/auth", AuthRoute);
await this.useRoute("/api/:project", ProjectRoute);
}
}

25
lib/vix/ClientErrors.ts Normal file
View file

@ -0,0 +1,25 @@
import { ClientError } from "@dunemask/vix/bridge";
export class AuthErrors {
static readonly UnauthorizedRequiredProject = new ClientError(401, "Project required!");
static readonly UnauthorizedRequiredToken = new ClientError(401, "Token required!");
static readonly UnauthorizedRequiredUser = new ClientError(401, "User not set!");
static readonly UnauthorizedRole = new ClientError(403, "Insufficient Privileges");
static readonly UnauthorizedRequest = new ClientError(401, "Unauthorized!");
static readonly ForbiddenPermissions = new ClientError(403, "Insufficient privileges!");
}
export class UserErrors {
static readonly ConflictIdentityTaken = new ClientError(409, "Identity taken!");
}
export class ProjectErrors {
static readonly BadRequestSlugInvalid = new ClientError(400, "Project slug invalid!");
static readonly BadRequestProjectIncomplete = new ClientError(400, "Project incomplete!");
static readonly UnexpectedRootUserError = new ClientError(500, "Error creating root user!");
static readonly ConflictNonUnique = new ClientError(409, "Slug already taken!");
}
export class KeyPairErrors {
static readonly NotFoundKeypair = new ClientError(400, "Keypair not found!");
}

9
lib/vix/RouteGuard.ts Normal file
View file

@ -0,0 +1,9 @@
import policyMiddlewareGuard from "@lib/middlewares/policy-guard";
import userGuard from "@lib/middlewares/user-guard";
import AppGuard from "./AppGuards";
export default class RouteGuard {
static User = userGuard();
static ManageProjectsRead = policyMiddlewareGuard(AppGuard.ManageProjects);
static MangeProjectsCreate = policyMiddlewareGuard(AppGuard.CreateProjects);
}