mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Add installation/site configuration endpoints
Summary: A new set of endpoints for managing installation and site configuration have been added: - GET `/api/install/configs/:key` - get the value of the configuration item with the specified key - PUT `/api/install/configs/:key` - set the value of the configuration item with the specified key - body: the JSON value of the configuration item - DELETE `/api/install/configs/:key` - delete the configuration item with the specified key - GET `/api/orgs/:oid/configs/:key` - get the value of the configuration item with the specified key - PUT `/api/orgs/:oid/configs/:key` - set the value of the configuration item with the specified key - body: the JSON value of the configuration item - DELETE `/api/orgs/:oid/configs/:key` - delete the configuration item with the specified key Configuration consists of key/value pairs, where keys are strings (e.g. `"audit_logs_streaming_destinations"`) and values are JSON, including literals like numbers and strings. Only installation admins and site owners are permitted to modify installation and site configuration, respectively. The endpoints are planned to be used in an upcoming feature for enabling audit log streaming for an installation and/or site. Future functionality may use the endpoints as well, which may require extending the current capabilities (e.g. adding support for storing secrets, additional metadata fields, etc.). Test Plan: Server tests Reviewers: paulfitz, jarek Reviewed By: paulfitz, jarek Subscribers: jarek Differential Revision: https://phab.getgrist.com/D4377
This commit is contained in:
parent
89468bd9f0
commit
ecff88bd32
26
app/common/Config-ti.ts
Normal file
26
app/common/Config-ti.ts
Normal file
@ -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;
|
25
app/common/Config.ts
Normal file
25
app/common/Config.ts
Normal file
@ -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<ConfigKey>;
|
||||
|
||||
const { AuditLogStreamingDestinations } = createCheckers(ConfigsTI);
|
||||
|
||||
export const ConfigValueCheckers = {
|
||||
audit_log_streaming_destinations:
|
||||
AuditLogStreamingDestinations as CheckerT<AuditLogStreamingDestinations>,
|
||||
};
|
43
app/gen-server/entity/Config.ts
Normal file
43
app/gen-server/entity/Config.ts
Normal file
@ -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;
|
||||
}
|
@ -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<QueryResult<Workspace>> {
|
||||
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<DocumentProperties>,
|
||||
@ -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<QueryResult<Config>> {
|
||||
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<QueryResult<Config|PreviousAndCurrent<Config>>> {
|
||||
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<QueryResult<Config>> {
|
||||
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<QueryResult<Config>> {
|
||||
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<QueryResult<Config|PreviousAndCurrent<Config>>> {
|
||||
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<QueryResult<Config>> {
|
||||
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<Config> {
|
||||
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<Config> {
|
||||
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, {
|
||||
|
66
app/gen-server/migration/1727747249153-Configs.ts
Normal file
66
app/gen-server/migration/1727747249153-Configs.ts
Normal file
@ -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<any> {
|
||||
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<any> {
|
||||
await queryRunner.dropTable("configs");
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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<InstallProperties>).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() {
|
||||
|
317
app/server/lib/attachEarlyEndpoints.ts
Normal file
317
app/server/lib/attachEarlyEndpoints.ts
Normal file
@ -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<InstallProperties>).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<Config | PreviousAndCurrent<Config>>
|
||||
) {
|
||||
if (!result.data) {
|
||||
return result as unknown as QueryResult<undefined>;
|
||||
}
|
||||
|
||||
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) });
|
||||
}
|
||||
}
|
@ -211,10 +211,11 @@ export async function sendReply<T>(
|
||||
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});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
351
test/server/lib/InstallConfig.ts
Normal file
351
test/server/lib/InstallConfig.ts
Normal file
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
325
test/server/lib/OrgConfig.ts
Normal file
325
test/server/lib/OrgConfig.ts
Normal file
@ -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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user