import { AccountProvisioning, AccountQuota, Feature, OrgProvisioning, OrgQuota, Provisioner, Provisioning, ProvisioningStatus, VaultProvisioning, VaultQuota, } from "@padloc/core/src/provisioning"; import { getIdFromEmail } from "@padloc/core/src/util"; import { Storage } from "@padloc/core/src/storage"; import { ErrorCode } from "@padloc/core/src/error"; import { Config, ConfigParam } from "@padloc/core/src/config"; import { createServer, IncomingMessage, ServerResponse } from "http"; import { readBody } from "../transport/http"; import { Account, AccountID } from "@padloc/core/src/account"; import { Org, OrgID } from "@padloc/core/src/org"; import { AsSerializable } from "@padloc/core/src/encoding"; export class DefaultAccountQuota extends Config implements AccountQuota { @ConfigParam("number") vaults = 1; @ConfigParam("number") orgs = 3; } export class DefaultAccountProvisioning extends Config implements Pick< AccountProvisioning, "status" | "statusLabel" | "statusMessage" | "actionUrl" | "actionLabel" | "quota" | "disableFeatures" > { @ConfigParam() status: ProvisioningStatus = ProvisioningStatus.Active; @ConfigParam() statusLabel: string = ""; @ConfigParam() statusMessage: string = ""; @ConfigParam() actionUrl?: string; @ConfigParam() actionLabel?: string; @ConfigParam(DefaultAccountQuota) quota: DefaultAccountQuota = new DefaultAccountQuota(); @ConfigParam("string[]") disableFeatures: Feature[] = []; } export class SimpleProvisionerConfig extends Config { @ConfigParam("number") port: number = 4000; @ConfigParam("string", true) apiKey?: string; @ConfigParam(DefaultAccountProvisioning) default: DefaultAccountProvisioning = new DefaultAccountProvisioning(); } interface ProvisioningUpdate { email: string; status: ProvisioningStatus; statusLabel: string; statusMessage: string; actionUrl?: string; actionLabel?: string; scheduled?: ScheduledProvisioningUpdate[]; metaData?: { [prop: string]: string }; } interface ScheduledProvisioningUpdate extends ProvisioningUpdate { time: number; } interface ProvisioningRequest { default: ProvisioningUpdate; updates: ProvisioningUpdate[]; } class ProvisioningEntry extends AccountProvisioning { constructor(vals: Partial = {}) { super(); Object.assign(this, vals); } id: string = ""; @AsSerializable(OrgQuota) orgQuota: OrgQuota = new OrgQuota(); @AsSerializable(VaultQuota) vaultQuota: VaultQuota = new VaultQuota(); scheduledUpdates: ScheduledProvisioningUpdate[] = []; metaData?: { [prop: string]: string } = undefined; } export class SimpleProvisioner implements Provisioner { constructor(public readonly config: SimpleProvisionerConfig, private readonly storage: Storage) {} private async _getProvisioningEntry({ email, accountId }: { email: string; accountId?: string | undefined }) { const id = await getIdFromEmail(email); try { const entry = await this.storage.get(ProvisioningEntry, id); entry.scheduledUpdates.sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()); const dueUpdate = entry.scheduledUpdates.filter((u) => new Date(u.time) <= new Date()).pop(); if (dueUpdate) { this._applyUpdate(entry, dueUpdate); entry.scheduledUpdates = entry.scheduledUpdates.filter((u) => new Date(u.time) > new Date()); await this.storage.save(entry); } return entry; } catch (e) { if (e.code !== ErrorCode.NOT_FOUND) { throw e; } } const provisioning = new ProvisioningEntry({ id, email, accountId, status: this.config.default.status, statusLabel: this.config.default.statusLabel, statusMessage: this.config.default.statusMessage, actionUrl: this.config.default.actionUrl, actionLabel: this.config.default.actionLabel, }); try { const { status, statusLabel, statusMessage, actionUrl, actionLabel } = await this.storage.get( ProvisioningEntry, "[default]" ); provisioning.status = status; provisioning.statusLabel = statusLabel; provisioning.statusMessage = statusMessage; provisioning.actionUrl = actionUrl; provisioning.actionLabel = actionLabel; } catch (e) {} return provisioning; } private async _getOrgProvisioning(account: Account, { id }: { id: OrgID }) { const org = await this.storage.get(Org, id); const { email, id: accountId } = org.isOwner(account) ? account : await this.storage.get(Account, org.owner); const { status, statusLabel, statusMessage, orgQuota, vaultQuota } = await this._getProvisioningEntry({ email, accountId, }); const vaults = org.getVaultsForMember(account); return { org: new OrgProvisioning({ orgId: org.id, status, statusLabel, statusMessage, quota: orgQuota, }), vaults: vaults.map( (v) => new VaultProvisioning({ vaultId: v.id, status, statusLabel, statusMessage, quota: vaultQuota, }) ), }; } async getProvisioning({ email, accountId }: { email: string; accountId?: AccountID }) { const provisioningEntry = await this._getProvisioningEntry({ email, accountId }); const provisioning = new Provisioning({ account: new AccountProvisioning({ ...provisioningEntry, quota: this.config.default.quota, disableFeatures: this.config.default.disableFeatures, }), }); if (accountId) { const account = await this.storage.get(Account, accountId); const orgs = await Promise.all(account.orgs.map((org) => this._getOrgProvisioning(account, org))); provisioning.orgs = orgs.map((o) => o.org); provisioning.vaults = [ new VaultProvisioning({ vaultId: account.mainVault.id, status: provisioningEntry.status, statusLabel: provisioningEntry.statusLabel, statusMessage: provisioningEntry.statusMessage, quota: provisioningEntry.vaultQuota, }), ...orgs.flatMap((o) => o.vaults), ]; } return provisioning; } async accountDeleted(_params: { email: string; accountId?: string }): Promise { // const id = await getIdFromEmail(email); // try { // const provisioning = await this.storage.get(ProvisioningEntry, id); // if (provisioning) { // await this.storage.delete(provisioning); // } // } catch (e) { // if (e.code !== ErrorCode.NOT_FOUND) { // throw e; // } // } } async init() { return this._startServer(); } private _applyUpdate(entry: ProvisioningEntry, update: ProvisioningUpdate) { entry.status = update.status; entry.statusLabel = update.statusLabel; entry.statusMessage = update.statusMessage; entry.actionUrl = update.actionUrl || this.config.default.actionUrl; entry.actionLabel = update.actionLabel || this.config.default.actionLabel; entry.metaData = update.metaData; } private async _handleRequest({ default: defaultProv, updates = [] }: ProvisioningRequest) { if (defaultProv) { const entry = new ProvisioningEntry(defaultProv); entry.id = "[default]"; await this.storage.save(entry); } for (const update of updates) { const entry = (await this._getProvisioningEntry({ email: update.email })) as ProvisioningEntry; this._applyUpdate(entry, update); entry.scheduledUpdates = update.scheduled || []; await this.storage.save(entry); } } private _validateUpdate(update: ProvisioningUpdate) { const validStatuses = Object.values(ProvisioningStatus); if (!validStatuses.includes(update.status)) { return `'updates.status' parameter must be one of ${validStatuses.map((s) => `"${s}"`).join(", ")}`; } if (typeof update.statusLabel !== "string") { return "'updates.statusLabel' parameter must be a string"; } if (typeof update.statusMessage !== "string") { return "'updates.statusMessage' parameter must be a string"; } if (typeof update.scheduled !== "undefined" && !Array.isArray(update.scheduled)) { return "'updates.scheduled' parameter must be an array!"; } if (typeof update.actionUrl !== "undefined" && typeof update.actionUrl !== "string") { return "'updates.actionUrl' parameter must be a string"; } if (update.actionUrl && !update.actionLabel) { return "If 'updates.actionUrl' is provided, 'updates.actionLabel' must be provided as well."; } if (typeof update.actionLabel !== "undefined" && typeof update.actionLabel !== "string") { return "'updates.actionLabel' parameter must be a string"; } return null; } private _validate(request: any): string | null { if (!request.updates && !request.default) { return "Request must contain either 'updates' or 'default' parameter"; } if (request.default) { const err = this._validateUpdate(request.default); if (err) { return err; } } if (request.updates && !Array.isArray(request.updates)) { return "'update' parameter should be an Array"; } for (const update of request.updates || []) { if (!update.email || typeof update.email !== "string") { return "'updates.email' parameter must be a non-empty string"; } const error = this._validateUpdate(update); if (error) { return error; } if (update.scheduled) { if (!Array.isArray(update.scheduled)) { return "'updates.scheduled' must be an array"; } for (const scheduled of update.scheduled) { const ts = new Date(scheduled.time).getTime(); if (isNaN(ts) || ts < Date.now()) { return "'scheduled.time' must be a valid time in the future!"; } const error = this._validateUpdate(scheduled); if (error) { return error; } } } } return null; } private async _handlePost(httpReq: IncomingMessage, httpRes: ServerResponse) { let request: ProvisioningRequest; try { const body = await readBody(httpReq); request = JSON.parse(body); } catch (e) { httpRes.statusCode = 400; httpRes.end("Failed to read request body."); return; } const validationError = this._validate(request); if (validationError) { httpRes.statusCode = 400; httpRes.end(validationError); return; } try { await this._handleRequest(request); } catch (e) { httpRes.statusCode = 500; httpRes.end("Unexpected Error"); return; } httpRes.statusCode = 200; httpRes.end(); } private async _handleGet(httpReq: IncomingMessage, httpRes: ServerResponse) { const email = new URL(httpReq.url!, "http://localhost").searchParams.get("email"); if (!email) { httpRes.statusCode = 400; httpRes.end("Missing parameter: 'email'"); return; } let entry: ProvisioningEntry; try { const id = await getIdFromEmail(email); entry = await this.storage.get(ProvisioningEntry, id); } catch (e) { if (e.code === ErrorCode.NOT_FOUND) { httpRes.statusCode = 404; httpRes.end(); return; } else { throw e; } } const { accountId, status, statusLabel, statusMessage, actionUrl, actionLabel, scheduledUpdates, metaData } = entry.toRaw(); httpRes.statusCode = 200; httpRes.end( JSON.stringify( { accountId, status, statusLabel, statusMessage, actionUrl, actionLabel, scheduledUpdates, metaData, }, null, 4 ) ); } async _startServer() { const server = createServer(async (httpReq, httpRes) => { if (this.config.apiKey) { let authHeader = httpReq.headers["authorization"]; authHeader = Array.isArray(authHeader) ? authHeader[0] : authHeader; const apiKeyMatch = authHeader?.match(/^Bearer (.+)$/); if (!apiKeyMatch || apiKeyMatch[1] !== this.config.apiKey) { httpRes.statusCode = 401; httpRes.end(); return; } } switch (httpReq.method) { case "POST": return this._handlePost(httpReq, httpRes); break; case "GET": return this._handleGet(httpReq, httpRes); default: httpRes.statusCode = 405; httpRes.end(); } }); server.listen(this.config.port); } }