diff --git a/app/common/Config-ti.ts b/app/common/Config-ti.ts new file mode 100644 index 00000000..f8555bfb --- /dev/null +++ b/app/common/Config-ti.ts @@ -0,0 +1,26 @@ +/** + * This module was automatically generated by `ts-interface-builder` + */ +import * as t from "ts-interface-checker"; +// tslint:disable:object-literal-key-quotes + +export const ConfigKey = t.lit("audit_log_streaming_destinations"); + +export const ConfigValue = t.name("AuditLogStreamingDestinations"); + +export const AuditLogStreamingDestinations = t.array("AuditLogStreamingDestination"); + +export const AuditLogStreamingDestination = t.iface([], { + "id": "string", + "name": t.lit("splunk"), + "url": "string", + "token": t.opt("string"), +}); + +const exportedTypeSuite: t.ITypeSuite = { + ConfigKey, + ConfigValue, + AuditLogStreamingDestinations, + AuditLogStreamingDestination, +}; +export default exportedTypeSuite; diff --git a/app/common/Config.ts b/app/common/Config.ts new file mode 100644 index 00000000..b02682ac --- /dev/null +++ b/app/common/Config.ts @@ -0,0 +1,25 @@ +import ConfigsTI from "app/common/Config-ti"; +import { CheckerT, createCheckers } from "ts-interface-checker"; + +export type ConfigKey = "audit_log_streaming_destinations"; + +export type ConfigValue = AuditLogStreamingDestinations; + +export type AuditLogStreamingDestinations = AuditLogStreamingDestination[]; + +export interface AuditLogStreamingDestination { + id: string; + name: "splunk"; + url: string; + token?: string; +} + +export const ConfigKeyChecker = createCheckers(ConfigsTI) + .ConfigKey as CheckerT; + +const { AuditLogStreamingDestinations } = createCheckers(ConfigsTI); + +export const ConfigValueCheckers = { + audit_log_streaming_destinations: + AuditLogStreamingDestinations as CheckerT, +}; diff --git a/app/gen-server/entity/Config.ts b/app/gen-server/entity/Config.ts new file mode 100644 index 00000000..34b732f7 --- /dev/null +++ b/app/gen-server/entity/Config.ts @@ -0,0 +1,43 @@ +import { ConfigKey, ConfigValue } from "app/common/Config"; +import { Organization } from "app/gen-server/entity/Organization"; +import { nativeValues } from "app/gen-server/lib/values"; +import { + BaseEntity, + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from "typeorm"; + +@Entity({ name: "configs" }) +export class Config extends BaseEntity { + @PrimaryGeneratedColumn() + public id: number; + + @ManyToOne(() => Organization, { nullable: true }) + @JoinColumn({ name: "org_id" }) + public org: Organization | null; + + @Column({ type: String }) + public key: ConfigKey; + + @Column({ type: nativeValues.jsonEntityType }) + public value: ConfigValue; + + @CreateDateColumn({ + name: "created_at", + type: Date, + default: () => "CURRENT_TIMESTAMP", + }) + public createdAt: Date; + + @UpdateDateColumn({ + name: "updated_at", + type: Date, + default: () => "CURRENT_TIMESTAMP", + }) + public updatedAt: Date; +} diff --git a/app/gen-server/lib/homedb/HomeDBManager.ts b/app/gen-server/lib/homedb/HomeDBManager.ts index a5acfaa4..280177e7 100644 --- a/app/gen-server/lib/homedb/HomeDBManager.ts +++ b/app/gen-server/lib/homedb/HomeDBManager.ts @@ -1,6 +1,7 @@ import {ShareInfo} from 'app/common/ActiveDocAPI'; import {ApiError, LimitType} from 'app/common/ApiError'; import {mapGetOrSet, mapSetOrClear, MapWithTTL} from 'app/common/AsyncCreate'; +import {ConfigKey, ConfigValue} from 'app/common/Config'; import {getDataLimitInfo} from 'app/common/DocLimits'; import {createEmptyOrgUsageSummary, DocumentUsage, OrgUsageSummary} from 'app/common/DocUsage'; import {normalizeEmail} from 'app/common/emails'; @@ -30,6 +31,7 @@ import {AclRule, AclRuleDoc, AclRuleOrg, AclRuleWs} from "app/gen-server/entity/ import {Alias} from "app/gen-server/entity/Alias"; import {BillingAccount} from "app/gen-server/entity/BillingAccount"; import {BillingAccountManager} from "app/gen-server/entity/BillingAccountManager"; +import {Config} from "app/gen-server/entity/Config"; import {Document} from "app/gen-server/entity/Document"; import {Group} from "app/gen-server/entity/Group"; import {AccessOption, AccessOptionWithRole, Organization} from "app/gen-server/entity/Organization"; @@ -459,6 +461,9 @@ export class HomeDBManager extends EventEmitter { return await this._usersManager.ensureExternalUser(profile); } + /** + * @see UsersManager.prototype.updateUser + */ public async updateUser( userId: number, props: UserProfileChange @@ -1234,15 +1239,22 @@ export class HomeDBManager extends EventEmitter { return orgResult; } - // If setting anything more than prefs: - // Checks that the user has UPDATE permissions to the given org. If not, throws an - // error. Otherwise updates the given org with the given name. Returns a query - // result with status 200 on success. - // For setting userPrefs or userOrgPrefs: - // These are user-specific setting, so are allowed with VIEW access (that includes - // guests). Prefs are replaced in their entirety, not merged. - // For setting orgPrefs: - // These are not user-specific, so require UPDATE permissions. + /** + * Updates the properties of the specified org. + * + * - If setting anything more than prefs: + * - Checks that the user has UPDATE permissions to the given org. If + * not, throws an error. + * - For setting userPrefs or userOrgPrefs: + * - These are user-specific setting, so are allowed with VIEW access + * (that includes guests). Prefs are replaced in their entirety, not + * merged. + * - For setting orgPrefs: + * - These are not user-specific, so require UPDATE permissions. + * + * Returns a query result with status 200 and the previous and current + * versions of the org on success. + */ public async updateOrg( scope: Scope, orgKey: string|number, @@ -1432,9 +1444,14 @@ export class HomeDBManager extends EventEmitter { }); } - // Checks that the user has UPDATE permissions to the given workspace. If not, throws an - // error. Otherwise updates the given workspace with the given name. Returns a query result - // with status 200 on success. + /** + * Checks that the user has UPDATE permissions to the given workspace. If + * not, throws an error. Otherwise updates the given workspace with the given + * name. + * + * Returns a query result with status 200 and the previous and current + * versions of the workspace, on success. + */ public async updateWorkspace( scope: Scope, wsId: number, @@ -1460,9 +1477,11 @@ export class HomeDBManager extends EventEmitter { }); } - // Checks that the user has REMOVE permissions to the given workspace. If not, throws an - // error. Otherwise deletes the given workspace. Returns a query result with status 200 - // on success. + /** + * Checks that the user has REMOVE permissions to the given workspace. If not, throws an + * error. Otherwise deletes the given workspace. Returns a query result with status 200 + * and the deleted workspace on success. + */ public async deleteWorkspace(scope: Scope, wsId: number): Promise> { return await this._connection.transaction(async manager => { const wsQuery = this._workspace(scope, wsId, { @@ -1706,11 +1725,16 @@ export class HomeDBManager extends EventEmitter { }); } - // Checks that the user has SCHEMA_EDIT permissions to the given doc. If not, throws an - // error. Otherwise updates the given doc with the given name. Returns a query result with - // status 200 on success. - // NOTE: This does not update the updateAt date indicating the last modified time of the doc. - // We may want to make it do so. + /** + * Checks that the user has SCHEMA_EDIT permissions to the given doc. If not, + * throws an error. Otherwise updates the given doc with the given name. + * + * Returns a query result with status 200 and the previous and current + * versions of the doc on success. + * + * NOTE: This does not update the updateAt date indicating the last modified + * time of the doc. We may want to make it do so. + */ public async updateDocument( scope: DocScope, props: Partial, @@ -2302,6 +2326,12 @@ export class HomeDBManager extends EventEmitter { }; } + /** + * Moves the doc to the specified workspace. + * + * Returns a query result with status 200 and the previous and current + * versions of the doc on success. + */ public async moveDoc( scope: DocScope, wsId: number @@ -2818,6 +2848,247 @@ export class HomeDBManager extends EventEmitter { .getOne(); } + /** + * Gets the config with the specified `key`. + * + * Returns a query result with status 200 and the config on success. + * + * Fails if a config with the specified `key` does not exist. + */ + public async getInstallConfig( + key: ConfigKey, + { transaction }: { transaction?: EntityManager } = {} + ): Promise> { + return this._runInTransaction(transaction, (manager) => { + const query = this._installConfig(key, { + manager, + }); + return verifyEntity(query, { skipPermissionCheck: true }); + }); + } + + /** + * Updates the value of the config with the specified `key`. + * + * If a config with the specified `key` does not exist, returns a query + * result with status 201 and a new config on success. + * + * Otherwise, returns a query result with status 200 and the previous and + * current versions of the config on success. + */ + public async updateInstallConfig( + key: ConfigKey, + value: ConfigValue + ): Promise>> { + return await this._connection.transaction(async (manager) => { + const queryResult = await this.getInstallConfig(key, { + transaction: manager, + }); + if (queryResult.status === 404) { + const config: Config = new Config(); + config.key = key; + config.value = value; + await manager.save(config); + return { + status: 201, + data: config, + }; + } else { + const config: Config = this.unwrapQueryResult(queryResult); + const previous = structuredClone(config); + config.value = value; + await manager.save(config); + return { + status: 200, + data: { previous, current: config }, + }; + } + }); + } + + /** + * Deletes the config with the specified `key`. + * + * Returns a query result with status 200 and the deleted config on success. + * + * Fails if a config with the specified `key` does not exist. + */ + public async deleteInstallConfig(key: ConfigKey): Promise> { + return await this._connection.transaction(async (manager) => { + const queryResult = await this.getInstallConfig(key, { + transaction: manager, + }); + const config: Config = this.unwrapQueryResult(queryResult); + const deletedConfig = structuredClone(config); + await manager.remove(config); + return { + status: 200, + data: deletedConfig, + }; + }); + } + + /** + * Gets the config scoped to a particular `org` with the specified `key`. + * + * Returns a query result with status 200 and the config on success. + * + * Fails if the scoped user is not an owner of the org, or a config with + * the specified `key` does not exist for the org. + */ + public async getOrgConfig( + scope: Scope, + org: string|number, + key: ConfigKey, + options: { manager?: EntityManager } = {} + ): Promise> { + return this._runInTransaction(options.manager, (manager) => { + const query = this._orgConfig(scope, org, key, { + manager, + }); + return verifyEntity(query); + }); + } + + /** + * Updates the value of the config scoped to a particular `org` with the + * specified `key`. + * + * If a config with the specified `key` does not exist, returns a query + * result with status 201 and a new config on success. + * + * Otherwise, returns a query result with status 200 and the previous and + * current versions of the config on success. + * + * Fails if the user is not an owner of the org. + */ + public async updateOrgConfig( + scope: Scope, + orgKey: string|number, + key: ConfigKey, + value: ConfigValue + ): Promise>> { + return await this._connection.transaction(async (manager) => { + const orgQuery = this.org(scope, orgKey, { + markPermissions: Permissions.OWNER, + needRealOrg: true, + manager, + }); + const orgQueryResult = await verifyEntity(orgQuery); + const org: Organization = this.unwrapQueryResult(orgQueryResult); + const configQueryResult = await this.getOrgConfig(scope, orgKey, key, { + manager, + }); + if (configQueryResult.status === 404) { + const config: Config = new Config(); + config.key = key; + config.value = value; + config.org = org; + await manager.save(config); + return { + status: 201, + data: config, + }; + } else { + const config: Config = this.unwrapQueryResult(configQueryResult); + const previous = structuredClone(config); + config.value = value; + await manager.save(config); + return { + status: 200, + data: { previous, current: config }, + }; + } + }); + } + + /** + * Deletes the config scoped to a particular `org` with the specified `key`. + * + * Returns a query result with status 204 and the deleted config on success. + * + * Fails if the scoped user is not an owner of the org, or a config with + * the specified `key` does not exist for the org. + */ + public async deleteOrgConfig( + scope: Scope, + org: string|number, + key: ConfigKey + ): Promise> { + return await this._connection.transaction(async (manager) => { + const query = this._orgConfig(scope, org, key, { + manager, + }); + const queryResult = await verifyEntity(query); + const config: Config = this.unwrapQueryResult(queryResult); + const deletedConfig = structuredClone(config); + await manager.remove(config); + return { + status: 200, + data: deletedConfig, + }; + }); + } + + /** + * Gets the config with the specified `key` and `orgId`. + * + * Returns `null` if no matching config is found. + */ + public async getConfigByKeyAndOrgId( + key: ConfigKey, + orgId: number|null = null, + { manager }: { manager?: EntityManager } = {} + ) { + let query = this._configs(manager).where("configs.key = :key", { key }); + if (orgId !== null) { + query = query.andWhere("configs.org_id = :orgId", { orgId }); + } else { + query = query.andWhere("configs.org_id IS NULL"); + } + return query.getOne(); + } + + private _installConfig( + key: ConfigKey, + { manager }: { manager?: EntityManager } + ): SelectQueryBuilder { + return this._configs(manager).where( + "configs.key = :key AND configs.org_id is NULL", + { key } + ); + } + + private _orgConfig( + scope: Scope, + org: string|number, + key: ConfigKey, + { manager }: { manager?: EntityManager } + ): SelectQueryBuilder { + let query = this._configs(manager) + .where("configs.key = :key", { key }) + .leftJoinAndSelect("configs.org", "orgs"); + if (this.isMergedOrg(org)) { + query = query.where("orgs.owner_id = :userId", { userId: scope.userId }); + } else { + query = this._whereOrg(query, org, false); + } + const effectiveUserId = scope.userId; + const threshold = Permissions.OWNER; + query = query.addSelect( + this._markIsPermitted("orgs", effectiveUserId, "open", threshold), + "is_permitted" + ); + return query; + } + + private _configs(manager?: EntityManager) { + return (manager || this._connection) + .createQueryBuilder() + .select("configs") + .from(Config, "configs"); + } + private async _getOrgMembers(org: string|number|Organization) { if (!(org instanceof Organization)) { const orgQuery = this._org(null, false, org, { diff --git a/app/gen-server/migration/1727747249153-Configs.ts b/app/gen-server/migration/1727747249153-Configs.ts new file mode 100644 index 00000000..b4d4e078 --- /dev/null +++ b/app/gen-server/migration/1727747249153-Configs.ts @@ -0,0 +1,66 @@ +import { nativeValues } from "app/gen-server/lib/values"; +import * as sqlUtils from "app/gen-server/sqlUtils"; +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +export class Configs1727747249153 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const dbType = queryRunner.connection.driver.options.type; + const datetime = sqlUtils.datetime(dbType); + const now = sqlUtils.now(dbType); + + await queryRunner.createTable( + new Table({ + name: "configs", + columns: [ + { + name: "id", + type: "integer", + isGenerated: true, + generationStrategy: "increment", + isPrimary: true, + }, + { + name: "org_id", + type: "integer", + isNullable: true, + }, + { + name: "key", + type: "varchar", + }, + { + name: "value", + type: nativeValues.jsonType, + }, + { + name: "created_at", + type: datetime, + default: now, + }, + { + name: "updated_at", + type: datetime, + default: now, + }, + ], + foreignKeys: [ + { + columnNames: ["org_id"], + referencedColumnNames: ["id"], + referencedTableName: "orgs", + onDelete: "CASCADE", + }, + ], + }) + ); + + await queryRunner.manager.query( + 'CREATE UNIQUE INDEX "configs__key__org_id" ON "configs" ' + + "(key, COALESCE(org_id, 0))" + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("configs"); + } +} diff --git a/app/server/MergedServer.ts b/app/server/MergedServer.ts index 2ac994d1..33cf2b0b 100644 --- a/app/server/MergedServer.ts +++ b/app/server/MergedServer.ts @@ -156,9 +156,12 @@ export class MergedServer { if (!this.hasComponent("docs")) { this.flexServer.addDocApiForwarder(); } + await this.flexServer.addLandingPages(); + // Early endpoints use their own json handlers, so they come before + // `addJsonSupport`. + this.flexServer.addEarlyApi(); this.flexServer.addJsonSupport(); this.flexServer.addUpdatesCheck(); - await this.flexServer.addLandingPages(); // todo: add support for home api to standalone app this.flexServer.addHomeApi(); this.flexServer.addBillingApi(); @@ -172,7 +175,6 @@ export class MergedServer { this.flexServer.addWelcomePaths(); this.flexServer.addLogEndpoint(); this.flexServer.addGoogleAuthEndpoint(); - this.flexServer.addInstallEndpoints(); this.flexServer.addConfigEndpoints(); } diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 45297904..adef4754 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -1,12 +1,11 @@ import {ApiError} from 'app/common/ApiError'; import {ICustomWidget} from 'app/common/CustomWidget'; import {delay} from 'app/common/delay'; -import {commonUrls, encodeUrl, getSlugIfNeeded, GristDeploymentType, GristDeploymentTypes, +import {encodeUrl, getSlugIfNeeded, GristDeploymentType, GristDeploymentTypes, GristLoadConfig, IGristUrlState, isOrgInPathOnly, parseSubdomain, sanitizePathTail} from 'app/common/gristUrls'; import {getOrgUrlInfo} from 'app/common/gristUrls'; import {isAffirmative, safeJsonParse} from 'app/common/gutil'; -import {InstallProperties} from 'app/common/InstallAPI'; import {UserProfile} from 'app/common/LoginSessionAPI'; import {SandboxInfo} from 'app/common/SandboxInfo'; import {tbind} from 'app/common/tbind'; @@ -26,11 +25,11 @@ import {AccessTokens, IAccessTokens} from 'app/server/lib/AccessTokens'; import {createSandbox} from 'app/server/lib/ActiveDoc'; import {attachAppEndpoint} from 'app/server/lib/AppEndpoint'; import {appSettings} from 'app/server/lib/AppSettings'; +import {attachEarlyEndpoints} from 'app/server/lib/attachEarlyEndpoints'; import {IAuditLogger} from 'app/server/lib/AuditLogger'; import {addRequestUser, getTransitiveHeaders, getUser, getUserId, isAnonymousUser, isSingleUserMode, redirectToLoginUnconditionally} from 'app/server/lib/Authorizer'; import {redirectToLogin, RequestWithLogin, signInStatusMiddleware} from 'app/server/lib/Authorizer'; -import {BootProbes} from 'app/server/lib/BootProbes'; import {forceSessionChange} from 'app/server/lib/BrowserSession'; import {Comm} from 'app/server/lib/Comm'; import {create} from 'app/server/lib/create'; @@ -57,14 +56,14 @@ import {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint' import {PluginManager} from 'app/server/lib/PluginManager'; import * as ProcessMonitor from 'app/server/lib/ProcessMonitor'; import {adaptServerUrl, getOrgUrl, getOriginUrl, getScope, integerParam, isParameterOn, optIntegerParam, - optStringParam, RequestWithGristInfo, sendOkReply, stringArrayParam, stringParam, TEST_HTTPS_OFFSET, + optStringParam, RequestWithGristInfo, stringArrayParam, stringParam, TEST_HTTPS_OFFSET, trustOrigin} from 'app/server/lib/requestUtils'; import {ISendAppPageOptions, makeGristConfig, makeMessagePage, makeSendAppPage} from 'app/server/lib/sendAppPage'; import {getDatabaseUrl, listenPromise, timeoutReached} from 'app/server/lib/serverUtils'; import {Sessions} from 'app/server/lib/Sessions'; import * as shutdown from 'app/server/lib/shutdown'; import {TagChecker} from 'app/server/lib/TagChecker'; -import {getTelemetryPrefs, ITelemetry} from 'app/server/lib/Telemetry'; +import {ITelemetry} from 'app/server/lib/Telemetry'; import {startTestingHooks} from 'app/server/lib/TestingHooks'; import {getTestLoginSystem} from 'app/server/lib/TestLogin'; import {UpdateManager} from 'app/server/lib/UpdateManager'; @@ -1548,10 +1547,7 @@ export class FlexServer implements GristServer { * we need to get these webhooks in before the bodyParser is added to parse json. */ public addEarlyWebhooks() { - if (this._check('webhooks', 'homedb')) { return; } - if (this.deps.has('json')) { - throw new Error('addEarlyWebhooks called too late'); - } + if (this._check('webhooks', 'homedb', '!json')) { return; } this._getBilling(); this._billing.addWebhooks(this.app); } @@ -1885,99 +1881,26 @@ export class FlexServer implements GristServer { addGoogleAuthEndpoint(this.app, messagePage); } - public addInstallEndpoints() { - if (this._check('install')) { return; } + /** + * Adds early API. + * + * These API endpoints are intentionally added before other middleware to + * minimize the impact of failures during startup. This includes, for + * example, endpoints used by the Admin Panel for status checks. + * + * It's also desirable for some endpoints to be loaded early so that they + * can set their own middleware, before any defaults are added. + * For example, `addJsonSupport` enforces strict parsing of JSON, but a + * handful of endpoints need relaxed parsing (e.g. /configs). + */ + public addEarlyApi() { + if (this._check('early-api', 'api-mw', 'homedb', '!json')) { return; } - const requireInstallAdmin = this.getInstallAdmin().getMiddlewareRequireAdmin(); - - // Admin endpoint needs to have very little middleware since each - // piece of middleware creates a new way to fail and leave the admin - // panel inaccessible. Generally the admin panel should report problems - // rather than failing entirely. - this.app.get('/admin', this._userIdMiddleware, expressWrap(async (req, resp) => { - return this.sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}}); - })); - const adminMiddleware = [ - this._userIdMiddleware, - requireInstallAdmin, - ]; - const probes = new BootProbes(this.app, this, '/api', adminMiddleware); - probes.addEndpoints(); - - this.app.post('/api/admin/restart', requireInstallAdmin, expressWrap(async (_, resp) => { - resp.on('finish', () => { - // If we have IPC with parent process (e.g. when running under - // Docker) tell the parent that we have a new environment so it - // can restart us. - if (process.send) { - process.send({ action: 'restart' }); - } - }); - - if(!process.env.GRIST_RUNNING_UNDER_SUPERVISOR) { - // On the topic of http response codes, thus spake MDN: - // "409: This response is sent when a request conflicts with the current state of the server." - return resp.status(409).send({ - error: "Cannot automatically restart the Grist server to enact changes. Please restart server manually." - }); - } - return resp.status(200).send({ msg: 'ok' }); - })); - - // Restrict this endpoint to install admins - this.app.get('/api/install/prefs', requireInstallAdmin, expressWrap(async (_req, resp) => { - const activation = await this._activations.current(); - - return sendOkReply(null, resp, { - telemetry: await getTelemetryPrefs(this._dbManager, activation), - }); - })); - - this.app.patch('/api/install/prefs', requireInstallAdmin, expressWrap(async (req, resp) => { - const props = {prefs: req.body}; - const activation = await this._activations.current(); - activation.checkProperties(props); - activation.updateFromProperties(props); - await activation.save(); - - if ((props as Partial).prefs?.telemetry) { - // Make sure the Telemetry singleton picks up the changes to telemetry preferences. - // TODO: if there are multiple home server instances, notify them all of changes to - // preferences (via Redis Pub/Sub). - await this._telemetry.fetchTelemetryPrefs(); - } - - return resp.status(200).send(); - })); - - // GET api/checkUpdates - // Retrieves the latest version of the client from Grist SAAS endpoint. - this.app.get('/api/install/updates', adminMiddleware, expressWrap(async (req, res) => { - // Prepare data for the telemetry that endpoint might expect. - const installationId = (await this.getActivations().current()).id; - const deploymentType = this.getDeploymentType(); - const currentVersion = version.version; - const response = await fetch(process.env.GRIST_TEST_VERSION_CHECK_URL || commonUrls.versionCheck, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - installationId, - deploymentType, - currentVersion, - }), - }); - if (!response.ok) { - res.status(response.status); - if (response.headers.get('content-type')?.includes('application/json')) { - const data = await response.json(); - res.json(data); - } else { - res.send(await response.text()); - } - } else { - res.json(await response.json()); - } - })); + attachEarlyEndpoints({ + app: this.app, + gristServer: this, + userIdMiddleware: this._userIdMiddleware, + }); } public addConfigEndpoints() { diff --git a/app/server/lib/attachEarlyEndpoints.ts b/app/server/lib/attachEarlyEndpoints.ts new file mode 100644 index 00000000..9b6bdeee --- /dev/null +++ b/app/server/lib/attachEarlyEndpoints.ts @@ -0,0 +1,317 @@ +import { ApiError } from "app/common/ApiError"; +import { + ConfigKey, + ConfigKeyChecker, + ConfigValue, + ConfigValueCheckers, +} from "app/common/Config"; +import { commonUrls } from "app/common/gristUrls"; +import { InstallProperties } from "app/common/InstallAPI"; +import * as version from "app/common/version"; +import { getOrgKey } from "app/gen-server/ApiServer"; +import { Config } from "app/gen-server/entity/Config"; +import { + PreviousAndCurrent, + QueryResult, +} from "app/gen-server/lib/homedb/Interfaces"; +import { BootProbes } from "app/server/lib/BootProbes"; +import { expressWrap } from "app/server/lib/expressWrap"; +import { GristServer } from "app/server/lib/GristServer"; +import log from "app/server/lib/log"; +import { + getScope, + sendOkReply, + sendReply, + stringParam, +} from "app/server/lib/requestUtils"; +import { getTelemetryPrefs } from "app/server/lib/Telemetry"; +import { + Application, + json, + NextFunction, + Request, + RequestHandler, + Response, +} from "express"; +import pick from "lodash/pick"; + +export interface AttachOptions { + app: Application; + gristServer: GristServer; + userIdMiddleware: RequestHandler; +} + +export function attachEarlyEndpoints(options: AttachOptions) { + const { app, gristServer, userIdMiddleware } = options; + + // Admin endpoint needs to have very little middleware since each + // piece of middleware creates a new way to fail and leave the admin + // panel inaccessible. Generally the admin panel should report problems + // rather than failing entirely. + app.get( + "/admin", + userIdMiddleware, + expressWrap(async (req, res) => { + return gristServer.sendAppPage(req, res, { + path: "app.html", + status: 200, + config: {}, + }); + }) + ); + + const requireInstallAdmin = gristServer + .getInstallAdmin() + .getMiddlewareRequireAdmin(); + + const adminMiddleware = [requireInstallAdmin]; + app.use("/api/admin", adminMiddleware); + app.use("/api/install", adminMiddleware); + + const probes = new BootProbes(app, gristServer, "/api", adminMiddleware); + probes.addEndpoints(); + + app.post( + "/api/admin/restart", + expressWrap(async (_, res) => { + res.on("finish", () => { + // If we have IPC with parent process (e.g. when running under + // Docker) tell the parent that we have a new environment so it + // can restart us. + if (process.send) { + process.send({ action: "restart" }); + } + }); + + if (!process.env.GRIST_RUNNING_UNDER_SUPERVISOR) { + // On the topic of http response codes, thus spake MDN: + // "409: This response is sent when a request conflicts with the current state of the server." + return res.status(409).send({ + error: + "Cannot automatically restart the Grist server to enact changes. Please restart server manually.", + }); + } + return res.status(200).send({ msg: "ok" }); + }) + ); + + // Restrict this endpoint to install admins. + app.get( + "/api/install/prefs", + expressWrap(async (_req, res) => { + const activation = await gristServer.getActivations().current(); + + return sendOkReply(null, res, { + telemetry: await getTelemetryPrefs( + gristServer.getHomeDBManager(), + activation + ), + }); + }) + ); + + app.patch( + "/api/install/prefs", + json({ limit: "1mb" }), + expressWrap(async (req, res) => { + const props = { prefs: req.body }; + const activation = await gristServer.getActivations().current(); + activation.checkProperties(props); + activation.updateFromProperties(props); + await activation.save(); + + if ((props as Partial).prefs?.telemetry) { + // Make sure the Telemetry singleton picks up the changes to telemetry preferences. + // TODO: if there are multiple home server instances, notify them all of changes to + // preferences (via Redis Pub/Sub). + await gristServer.getTelemetry().fetchTelemetryPrefs(); + } + + return res.status(200).send(); + }) + ); + + // Retrieves the latest version of the client from Grist SAAS endpoint. + app.get( + "/api/install/updates", + expressWrap(async (_req, res) => { + // Prepare data for the telemetry that endpoint might expect. + const installationId = (await gristServer.getActivations().current()).id; + const deploymentType = gristServer.getDeploymentType(); + const currentVersion = version.version; + const response = await fetch( + process.env.GRIST_TEST_VERSION_CHECK_URL || commonUrls.versionCheck, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + installationId, + deploymentType, + currentVersion, + }), + } + ); + if (!response.ok) { + res.status(response.status); + if ( + response.headers.get("content-type")?.includes("application/json") + ) { + const data = await response.json(); + res.json(data); + } else { + res.send(await response.text()); + } + } else { + res.json(await response.json()); + } + }) + ); + + app.get( + "/api/install/configs/:key", + hasValidConfigKey, + expressWrap(async (req, res) => { + const key = stringParam(req.params.key, "key") as ConfigKey; + const configResult = await gristServer + .getHomeDBManager() + .getInstallConfig(key); + const result = pruneConfigAPIResult(configResult); + return sendReply(req, res, result); + }) + ); + + app.put( + "/api/install/configs/:key", + json({ limit: "1mb", strict: false }), + hasValidConfig, + expressWrap(async (req, res) => { + const key = stringParam(req.params.key, "key") as ConfigKey; + const value = req.body as ConfigValue; + const configResult = await gristServer + .getHomeDBManager() + .updateInstallConfig(key, value); + const result = pruneConfigAPIResult(configResult); + return sendReply(req, res, result); + }) + ); + + app.delete( + "/api/install/configs/:key", + hasValidConfigKey, + expressWrap(async (req, res) => { + const key = stringParam(req.params.key, "key") as ConfigKey; + const { status } = await gristServer + .getHomeDBManager() + .deleteInstallConfig(key); + return sendReply(req, res, { status }); + }) + ); + + app.get( + "/api/orgs/:oid/configs/:key", + hasValidConfigKey, + expressWrap(async (req, res) => { + const org = getOrgKey(req); + const key = stringParam(req.params.key, "key") as ConfigKey; + const configResult = await gristServer + .getHomeDBManager() + .getOrgConfig(getScope(req), org, key); + const result = pruneConfigAPIResult(configResult); + return sendReply(req, res, result); + }) + ); + + app.put( + "/api/orgs/:oid/configs/:key", + json({ limit: "1mb", strict: false }), + hasValidConfig, + expressWrap(async (req, res) => { + const key = stringParam(req.params.key, "key") as ConfigKey; + const org = getOrgKey(req); + const value = req.body as ConfigValue; + const configResult = await gristServer + .getHomeDBManager() + .updateOrgConfig(getScope(req), org, key, value); + const result = pruneConfigAPIResult(configResult); + return sendReply(req, res, result); + }) + ); + + app.delete( + "/api/orgs/:oid/configs/:key", + hasValidConfigKey, + expressWrap(async (req, res) => { + const org = getOrgKey(req); + const key = stringParam(req.params.key, "key") as ConfigKey; + const { status } = await gristServer + .getHomeDBManager() + .deleteOrgConfig(getScope(req), org, key); + return sendReply(req, res, { status }); + }) + ); +} + +function pruneConfigAPIResult( + result: QueryResult> +) { + if (!result.data) { + return result as unknown as QueryResult; + } + + const config = "previous" in result.data ? result.data.current : result.data; + return { + ...result, + data: { + ...pick(config, "id", "key", "value", "createdAt", "updatedAt"), + ...(config.org + ? { org: pick(config.org, "id", "name", "domain") } + : undefined), + }, + }; +} + +function hasValidConfig(req: Request, _res: Response, next: NextFunction) { + try { + assertValidConfig(req); + next(); + } catch (e) { + next(e); + } +} + +function hasValidConfigKey(req: Request, _res: Response, next: NextFunction) { + try { + assertValidConfigKey(req); + next(); + } catch (e) { + next(e); + } +} + +function assertValidConfig(req: Request) { + assertValidConfigKey(req); + const key = stringParam(req.params.key, "key") as ConfigKey; + try { + ConfigValueCheckers[key].check(req.body); + } catch (err) { + log.warn( + `Error during API call to ${req.path}: invalid config value (${String( + err + )})` + ); + throw new ApiError("Invalid config value", 400, { userError: String(err) }); + } +} + +function assertValidConfigKey(req: Request) { + try { + ConfigKeyChecker.check(req.params.key); + } catch (err) { + log.warn( + `Error during API call to ${req.path}: invalid config key (${String( + err + )})` + ); + throw new ApiError("Invalid config key", 400, { userError: String(err) }); + } +} diff --git a/app/server/lib/requestUtils.ts b/app/server/lib/requestUtils.ts index 5ed0ae4d..874b712f 100644 --- a/app/server/lib/requestUtils.ts +++ b/app/server/lib/requestUtils.ts @@ -211,10 +211,11 @@ export async function sendReply( result: data, }); } - if (result.status === 200) { + res.status(result.status); + if (result.status >= 200 && result.status < 300) { return res.json(data ?? null); // can't handle undefined } else { - return res.status(result.status).json({error: result.errMessage}); + return res.json({error: result.errMessage}); } } diff --git a/test/gen-server/migrations.ts b/test/gen-server/migrations.ts index bc8dd1b1..fc608d9e 100644 --- a/test/gen-server/migrations.ts +++ b/test/gen-server/migrations.ts @@ -46,6 +46,7 @@ import {UserLastConnection1713186031023 as UserLastConnection} from 'app/gen-server/migration/1713186031023-UserLastConnection'; import {ActivationEnabled1722529827161 as ActivationEnabled} from 'app/gen-server/migration/1722529827161-Activation-Enabled'; +import {Configs1727747249153 as Configs} from 'app/gen-server/migration/1727747249153-Configs'; const home: HomeDBManager = new HomeDBManager(); @@ -55,7 +56,7 @@ const migrations = [Initial, Login, PinDocs, UserPicture, DisplayEmail, DisplayE ExternalBilling, DocOptions, Secret, UserOptions, GracePeriodStart, DocumentUsage, Activations, UserConnectId, UserUUID, UserUniqueRefUUID, Forks, ForkIndexes, ActivationPrefs, AssistantLimit, Shares, BillingFeatures, - UserLastConnection, ActivationEnabled]; + UserLastConnection, ActivationEnabled, Configs]; // Assert that the "members" acl rule and group exist (or not). function assertMembersGroup(org: Organization, exists: boolean) { diff --git a/test/server/lib/InstallConfig.ts b/test/server/lib/InstallConfig.ts new file mode 100644 index 00000000..35fc65c3 --- /dev/null +++ b/test/server/lib/InstallConfig.ts @@ -0,0 +1,351 @@ +import { Config } from "app/gen-server/entity/Config"; +import { HomeDBManager } from "app/gen-server/lib/homedb/HomeDBManager"; +import axios from "axios"; +import * as chai from "chai"; +import omit from "lodash/omit"; +import { TestServer } from "test/gen-server/apiUtils"; +import { configForUser } from "test/gen-server/testUtils"; +import * as testUtils from "test/server/testUtils"; + +describe("InstallConfig", function () { + const assert = chai.assert; + + let server: TestServer; + let dbManager: HomeDBManager; + let homeUrl: string; + + const chimpy = configForUser("Chimpy"); + const kiwi = configForUser("Kiwi"); + const support = configForUser("Support"); + const anonymous = configForUser("Anonymous"); + + const chimpyEmail = "chimpy@getgrist.com"; + + let oldEnv: testUtils.EnvironmentSnapshot; + + testUtils.setTmpLogLevel("error"); + + before(async function () { + oldEnv = new testUtils.EnvironmentSnapshot(); + process.env.GRIST_DEFAULT_EMAIL = chimpyEmail; + server = new TestServer(this); + homeUrl = await server.start(["home"]); + dbManager = server.dbManager; + }); + + after(async function () { + oldEnv.restore(); + await server.stop(); + }); + + describe("GET /api/install/configs/:key", async function () { + let config: Config; + + before(async function () { + await dbManager.connection.transaction(async (manager) => { + config = new Config(); + config.key = "audit_log_streaming_destinations"; + config.value = [ + { + id: "4e9f3c26-d069-43f2-8388-1f0f906c0ca3", + name: "splunk", + url: "https://hec.example.com:8088/services/collector/event", + token: "Splunk B5A79AAD-D822-46CC-80D1-819F80D7BFB0", + }, + ]; + await manager.save(config); + }); + }); + + after(async function () { + await dbManager.connection.transaction((manager) => + manager.createQueryBuilder().delete().from(Config).execute() + ); + }); + + it("returns 200 on success", async function () { + const resp = await axios.get( + `${homeUrl}/api/install/configs/audit_log_streaming_destinations`, + chimpy + ); + assert.equal(resp.status, 200); + assert.deepEqual(resp.data, { + id: 1, + key: "audit_log_streaming_destinations", + value: [ + { + id: "4e9f3c26-d069-43f2-8388-1f0f906c0ca3", + name: "splunk", + url: "https://hec.example.com:8088/services/collector/event", + token: "Splunk B5A79AAD-D822-46CC-80D1-819F80D7BFB0", + }, + ], + createdAt: config.createdAt.toISOString(), + updatedAt: config.updatedAt.toISOString(), + }); + }); + + it("returns 400 if key is invalid", async function () { + const resp = await axios.get( + `${homeUrl}/api/install/configs/invalid`, + chimpy + ); + assert.equal(resp.status, 400); + assert.deepEqual(resp.data, { + error: "Invalid config key", + details: { + userError: 'Error: value is not "audit_log_streaming_destinations"', + }, + }); + }); + + it("returns 403 if user isn't an install admin", async function () { + for (const user of [kiwi, anonymous]) { + const resp = await axios.get( + `${homeUrl}/api/install/configs/audit_log_streaming_destinations`, + user + ); + assert.equal(resp.status, 403); + assert.deepEqual(resp.data, { error: "Access denied" }); + } + + const resp = await axios.get( + `${homeUrl}/api/install/configs/audit_log_streaming_destinations`, + support + ); + assert.equal(resp.status, 200); + }); + + it("returns 404 if key doesn't exist", async function () { + await dbManager.connection.transaction((manager) => + manager.remove(config) + ); + + const resp = await axios.get( + `${homeUrl}/api/install/configs/audit_log_streaming_destinations`, + chimpy + ); + assert.equal(resp.status, 404); + assert.deepEqual(resp.data, { + error: "config not found", + }); + }); + }); + + describe("PUT /api/install/configs/:key", async function () { + after(async function () { + await dbManager.connection.transaction((manager) => + manager.createQueryBuilder().delete().from(Config).execute() + ); + }); + + function testCreateOrUpdate({ status }: { status: 200 | 201 }) { + return async function () { + const resp1 = await axios.put( + `${homeUrl}/api/install/configs/audit_log_streaming_destinations`, + [ + { + id: "4e9f3c26-d069-43f2-8388-1f0f906c0ca3", + name: "splunk", + url: "https://hec.example.com:8088/services/collector/event", + token: "Splunk B5A79AAD-D822-46CC-80D1-819F80D7BFB0", + }, + ], + chimpy + ); + assert.equal(resp1.status, status); + assert.deepEqual(omit(resp1.data, "createdAt", "updatedAt"), { + id: 2, + key: "audit_log_streaming_destinations", + value: [ + { + id: "4e9f3c26-d069-43f2-8388-1f0f906c0ca3", + name: "splunk", + url: "https://hec.example.com:8088/services/collector/event", + token: "Splunk B5A79AAD-D822-46CC-80D1-819F80D7BFB0", + }, + ], + }); + assert.hasAllKeys(resp1.data, [ + "id", + "key", + "value", + "createdAt", + "updatedAt", + ]); + + const resp2 = await axios.get( + `${homeUrl}/api/install/configs/audit_log_streaming_destinations`, + chimpy + ); + assert.equal(resp2.status, 200); + assert.deepEqual(resp2.data, resp1.data); + }; + } + + it( + "returns 201 if resource was created", + testCreateOrUpdate({ status: 201 }) + ); + + it( + "returns 200 if resource was updated", + testCreateOrUpdate({ status: 200 }) + ); + + it("returns 400 if key invalid", async function () { + const resp = await axios.put( + `${homeUrl}/api/install/configs/invalid`, + "invalid", + chimpy + ); + assert.equal(resp.status, 400); + assert.deepEqual(resp.data, { + error: "Invalid config key", + details: { + userError: 'Error: value is not "audit_log_streaming_destinations"', + }, + }); + }); + + it("returns 400 if body is invalid", async function () { + let resp = await axios.put( + `${homeUrl}/api/install/configs/audit_log_streaming_destinations`, + "invalid", + chimpy + ); + assert.equal(resp.status, 400); + assert.deepEqual(resp.data, { + error: "Invalid config value", + details: { + userError: "Error: value is not an array", + }, + }); + + resp = await axios.put( + `${homeUrl}/api/install/configs/audit_log_streaming_destinations`, + ["invalid"], + chimpy + ); + assert.equal(resp.status, 400); + assert.deepEqual(resp.data, { + error: "Invalid config value", + details: { + userError: + "Error: value[0] is not a AuditLogStreamingDestination; value[0] is not an object", + }, + }); + }); + + it("returns 403 if user isn't an install admin", async function () { + for (const user of [kiwi, anonymous]) { + const resp = await axios.put( + `${homeUrl}/api/install/configs/audit_log_streaming_destinations`, + [ + { + id: "4e9f3c26-d069-43f2-8388-1f0f906c0ca3", + name: "splunk", + url: "https://hec.example.com:8088/services/collector/event", + token: "Splunk B5A79AAD-D822-46CC-80D1-819F80D7BFB0", + }, + ], + user + ); + assert.equal(resp.status, 403); + assert.deepEqual(resp.data, { error: "Access denied" }); + } + + const resp = await axios.put( + `${homeUrl}/api/install/configs/audit_log_streaming_destinations`, + [ + { + id: "4e9f3c26-d069-43f2-8388-1f0f906c0ca3", + name: "splunk", + url: "https://hec.example.com:8088/services/collector/event", + token: "Splunk B5A79AAD-D822-46CC-80D1-819F80D7BFB0", + }, + ], + support + ); + assert.equal(resp.status, 200); + }); + }); + + describe("DELETE /api/install/configs/:key", async function () { + before(async function () { + await dbManager.connection.transaction(async (manager) => { + const config = new Config(); + config.key = "audit_log_streaming_destinations"; + config.value = [ + { + id: "4e9f3c26-d069-43f2-8388-1f0f906c0ca3", + name: "splunk", + url: "https://hec.example.com:8088/services/collector/event", + token: "Splunk B5A79AAD-D822-46CC-80D1-819F80D7BFB0", + }, + ]; + await manager.save(config); + }); + }); + + after(async function () { + await dbManager.connection.transaction((manager) => + manager.createQueryBuilder().delete().from(Config).execute() + ); + }); + + it("returns 200 on success", async function () { + let resp = await axios.delete( + `${homeUrl}/api/install/configs/audit_log_streaming_destinations`, + chimpy + ); + assert.equal(resp.status, 200); + assert.equal(resp.data, null); + + resp = await axios.get( + `${homeUrl}/api/install/configs/audit_log_streaming_destinations`, + chimpy + ); + assert.equal(resp.status, 404); + assert.deepEqual(resp.data, { + error: "config not found", + }); + }); + + it("returns 400 if key is invalid", async function () { + const resp = await axios.delete( + `${homeUrl}/api/install/configs/invalid`, + chimpy + ); + assert.equal(resp.status, 400); + assert.deepEqual(resp.data, { + error: "Invalid config key", + details: { + userError: 'Error: value is not "audit_log_streaming_destinations"', + }, + }); + }); + + it("returns 403 if user isn't an install admin", async function () { + for (const user of [kiwi, anonymous]) { + const resp = await axios.delete( + `${homeUrl}/api/install/configs/audit_log_streaming_destinations`, + user + ); + assert.equal(resp.status, 403); + assert.deepEqual(resp.data, { error: "Access denied" }); + } + }); + + it("returns 404 if key doesn't exist", async function () { + const resp = await axios.delete( + `${homeUrl}/api/install/configs/audit_log_streaming_destinations`, + support + ); + assert.equal(resp.status, 404); + assert.deepEqual(resp.data, { + error: "config not found", + }); + }); + }); +}); diff --git a/test/server/lib/OrgConfig.ts b/test/server/lib/OrgConfig.ts new file mode 100644 index 00000000..72bf13ae --- /dev/null +++ b/test/server/lib/OrgConfig.ts @@ -0,0 +1,325 @@ +import { Config } from "app/gen-server/entity/Config"; +import { HomeDBManager } from "app/gen-server/lib/homedb/HomeDBManager"; +import axios from "axios"; +import * as chai from "chai"; +import omit from "lodash/omit"; +import { TestServer } from "test/gen-server/apiUtils"; +import { configForUser } from "test/gen-server/testUtils"; +import * as testUtils from "test/server/testUtils"; + +describe("OrgConfig", function () { + const assert = chai.assert; + + let server: TestServer; + let dbManager: HomeDBManager; + let homeUrl: string; + + const chimpy = configForUser("Chimpy"); + const kiwi = configForUser("Kiwi"); + const support = configForUser("Support"); + const anonymous = configForUser("Anonymous"); + + const chimpyEmail = "chimpy@getgrist.com"; + + let oid: number | string; + + let oldEnv: testUtils.EnvironmentSnapshot; + + testUtils.setTmpLogLevel("error"); + + async function insertSampleConfig() { + await dbManager.connection.transaction(async (manager) => + manager + .createQueryBuilder() + .insert() + .into(Config) + .values([ + { + key: "audit_log_streaming_destinations", + value: [ + { + id: "4e9f3c26-d069-43f2-8388-1f0f906c0ca3", + name: "splunk", + url: "https://hec.example.com:8088/services/collector/event", + token: "Splunk B5A79AAD-D822-46CC-80D1-819F80D7BFB0", + }, + ], + org: () => String(oid), + }, + ]) + .execute() + ); + } + + async function deleteConfigs() { + await dbManager.connection.transaction((manager) => + manager.createQueryBuilder().delete().from(Config).execute() + ); + } + + before(async function () { + oldEnv = new testUtils.EnvironmentSnapshot(); + process.env.GRIST_DEFAULT_EMAIL = chimpyEmail; + server = new TestServer(this); + homeUrl = await server.start(["home"]); + dbManager = server.dbManager; + oid = (await dbManager.testGetId("NASA")) as number; + }); + + after(async function () { + oldEnv.restore(); + await server.stop(); + }); + + describe("GET /api/orgs/:oid/configs/:key", async function () { + after(async function () { + await deleteConfigs(); + }); + + it("returns 200 on success", async function () { + await insertSampleConfig(); + const resp = await axios.get( + `${homeUrl}/api/orgs/${oid}/configs/audit_log_streaming_destinations`, + chimpy + ); + assert.equal(resp.status, 200); + assert.deepEqual(omit(resp.data, "createdAt", "updatedAt"), { + org: { name: "NASA", id: 1, domain: "nasa" }, + id: 1, + key: "audit_log_streaming_destinations", + value: [ + { + id: "4e9f3c26-d069-43f2-8388-1f0f906c0ca3", + name: "splunk", + url: "https://hec.example.com:8088/services/collector/event", + token: "Splunk B5A79AAD-D822-46CC-80D1-819F80D7BFB0", + }, + ], + }); + assert.hasAllKeys(resp.data, [ + "org", + "id", + "key", + "value", + "createdAt", + "updatedAt", + ]); + }); + + it("returns 400 if key is invalid", async function () { + const resp = await axios.get( + `${homeUrl}/api/orgs/${oid}/configs/invalid`, + chimpy + ); + assert.equal(resp.status, 400); + assert.deepEqual(resp.data, { + error: "Invalid config key", + details: { + userError: 'Error: value is not "audit_log_streaming_destinations"', + }, + }); + }); + + it("returns 403 if user isn't an owner", async function () { + for (const user of [kiwi, anonymous, support]) { + const resp = await axios.get( + `${homeUrl}/api/orgs/${oid}/configs/audit_log_streaming_destinations`, + user + ); + assert.equal(resp.status, 403); + assert.deepEqual(resp.data, { error: "access denied" }); + } + }); + + it("returns 404 if key doesn't exist", async function () { + await dbManager.connection.transaction((manager) => + manager.createQueryBuilder().delete().from(Config).execute() + ); + + const resp = await axios.get( + `${homeUrl}/api/orgs/${oid}/configs/audit_log_streaming_destinations`, + chimpy + ); + assert.equal(resp.status, 404); + assert.deepEqual(resp.data, { + error: "config not found", + }); + }); + }); + + describe("PUT /api/orgs/:oid/configs/:key", async function () { + after(async function () { + await deleteConfigs(); + }); + + function testCreateOrUpdate({ status }: { status: 200 | 201 }) { + return async function () { + const resp1 = await axios.put( + `${homeUrl}/api/orgs/${oid}/configs/audit_log_streaming_destinations`, + [ + { + id: "4e9f3c26-d069-43f2-8388-1f0f906c0ca3", + name: "splunk", + url: "https://hec.example.com:8088/services/collector/event", + token: "Splunk B5A79AAD-D822-46CC-80D1-819F80D7BFB0", + }, + ], + chimpy + ); + assert.equal(resp1.status, status); + assert.deepEqual(omit(resp1.data, "createdAt", "updatedAt"), { + org: { name: "NASA", id: 1, domain: "nasa" }, + id: 2, + key: "audit_log_streaming_destinations", + value: [ + { + id: "4e9f3c26-d069-43f2-8388-1f0f906c0ca3", + name: "splunk", + url: "https://hec.example.com:8088/services/collector/event", + token: "Splunk B5A79AAD-D822-46CC-80D1-819F80D7BFB0", + }, + ], + }); + assert.hasAllKeys(resp1.data, [ + "org", + "id", + "key", + "value", + "createdAt", + "updatedAt", + ]); + + const resp2 = await axios.get( + `${homeUrl}/api/orgs/${oid}/configs/audit_log_streaming_destinations`, + chimpy + ); + assert.equal(resp2.status, 200); + assert.deepEqual(resp2.data, resp1.data); + }; + } + + it( + "returns 201 if resource was created", + testCreateOrUpdate({ status: 201 }) + ); + + it( + "returns 200 if resource was updated", + testCreateOrUpdate({ status: 200 }) + ); + + it("returns 400 if key invalid", async function () { + const resp = await axios.put( + `${homeUrl}/api/orgs/${oid}/configs/invalid`, + "invalid", + chimpy + ); + assert.equal(resp.status, 400); + assert.deepEqual(resp.data, { + error: "Invalid config key", + details: { + userError: 'Error: value is not "audit_log_streaming_destinations"', + }, + }); + }); + + it("returns 400 if body is invalid", async function () { + let resp = await axios.put( + `${homeUrl}/api/orgs/${oid}/configs/audit_log_streaming_destinations`, + "invalid", + chimpy + ); + assert.equal(resp.status, 400); + assert.deepEqual(resp.data, { + error: "Invalid config value", + details: { + userError: "Error: value is not an array", + }, + }); + + resp = await axios.put( + `${homeUrl}/api/orgs/${oid}/configs/audit_log_streaming_destinations`, + ["invalid"], + chimpy + ); + assert.equal(resp.status, 400); + assert.deepEqual(resp.data, { + error: "Invalid config value", + details: { + userError: + "Error: value[0] is not a AuditLogStreamingDestination; value[0] is not an object", + }, + }); + }); + + it("returns 403 if user isn't an owner", async function () { + for (const user of [kiwi, anonymous, support]) { + const resp = await axios.put( + `${homeUrl}/api/orgs/${oid}/configs/audit_log_streaming_destinations`, + [ + { + id: "4e9f3c26-d069-43f2-8388-1f0f906c0ca3", + name: "splunk", + url: "https://hec.example.com:8088/services/collector/event", + token: "Splunk B5A79AAD-D822-46CC-80D1-819F80D7BFB0", + }, + ], + user + ); + assert.equal(resp.status, 403); + assert.deepEqual(resp.data, { error: "access denied" }); + } + }); + }); + + describe("DELETE /api/orgs/:oid/configs/:key", async function () { + after(async function () { + await deleteConfigs(); + }); + + it("returns 200 on success", async function () { + await insertSampleConfig(); + let resp = await axios.delete( + `${homeUrl}/api/orgs/${oid}/configs/audit_log_streaming_destinations`, + chimpy + ); + assert.equal(resp.status, 200); + assert.equal(resp.data, null); + + resp = await axios.get( + `${homeUrl}/api/orgs/${oid}/configs/audit_log_streaming_destinations`, + chimpy + ); + assert.equal(resp.status, 404); + assert.deepEqual(resp.data, { + error: "config not found", + }); + }); + + it("returns 400 if key is invalid", async function () { + const resp = await axios.delete( + `${homeUrl}/api/orgs/${oid}/configs/invalid`, + chimpy + ); + assert.equal(resp.status, 400); + assert.deepEqual(resp.data, { + error: "Invalid config key", + details: { + userError: 'Error: value is not "audit_log_streaming_destinations"', + }, + }); + }); + + it("returns 403 if user isn't an owner", async function () { + await insertSampleConfig(); + for (const user of [kiwi, anonymous, support]) { + const resp = await axios.delete( + `${homeUrl}/api/orgs/${oid}/configs/audit_log_streaming_destinations`, + user + ); + assert.equal(resp.status, 403); + assert.deepEqual(resp.data, { error: "access denied" }); + } + }); + }); +});