(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:
George Gevoian
2024-10-15 20:45:10 -04:00
parent 89468bd9f0
commit ecff88bd32
12 changed files with 1477 additions and 126 deletions

View 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;
}

View File

@@ -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, {

View 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");
}
}