diff --git a/app/common/User.ts b/app/common/User.ts index 4e3419a148..f7eff6b32e 100644 --- a/app/common/User.ts +++ b/app/common/User.ts @@ -2,6 +2,16 @@ import {getTableId} from 'app/common/DocActions'; import {EmptyRecordView, RecordView} from 'app/common/RecordView'; import {Role} from 'app/common/roles'; +/** + * User type to distinguish beetween Users and service accounts + */ +export enum UserTypes { + 'login', + 'service' +} + +export type UserTypesStrings = keyof typeof UserTypes; + /** * Information about a user, including any user attributes. */ @@ -19,6 +29,7 @@ export interface UserInfo { * via a share. Otherwise null. */ ShareRef: number | null; + Type: UserTypes | null; [attributes: string]: unknown; } @@ -37,6 +48,7 @@ export class User implements UserInfo { public SessionID: string | null = null; public UserRef: string | null = null; public ShareRef: number | null = null; + public Type: UserTypes | null = null; [attribute: string]: any; constructor(info: Record = {}) { diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index 192bb8ed63..616c2cf69d 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -599,6 +599,35 @@ export class ApiServer { if (data) { this._logDeleteUserEvents(req, data); } return sendReply(req, res, result); })); + + // POST /service-accounts/ + // Creates a new service account attached to the user making the api call. + this._app.post('/api/service-accounts', expressWrap(async (req, res) => { + const userId = getAuthorizedUserId(req); + const serviceAccount: any = await this._dbManager.createServiceAccount(req.body); + return sendOkReply(req, res, { + key:serviceAccount.key, + }); + throw new ApiError(`${userId} post Not implemented yet ;)`, 501); + })); + + // GET /service-accounts/ + // Reads all service accounts attached to the user making the api call. + this._app.get('/api/service-accounts', expressWrap(async (req, res) => { + throw new ApiError('get Not implemented yet ;)', 501); + })); + + // GET /service-accounts/:said + // Reads one particular service account of the user making the api call. + this._app.get('/api/service-accounts/:said', expressWrap(async (req, res) => { + throw new ApiError('get by id Not implemented yet ;)', 501); + })); + + // DELETE /service-accounts/:said + // Deletes one particular service account of the user making the api call. + this._app.delete('/api/service-accounts/:said', expressWrap(async (req, res) => { + throw new ApiError('delete by id Not implemented yet ;)', 501); + })); } private async _getFullUser(req: Request, options: {includePrefs?: boolean} = {}): Promise { diff --git a/app/gen-server/entity/ServiceAccount.ts b/app/gen-server/entity/ServiceAccount.ts new file mode 100644 index 0000000000..0a687822f6 --- /dev/null +++ b/app/gen-server/entity/ServiceAccount.ts @@ -0,0 +1,20 @@ +import {BaseEntity, Column, Entity, PrimaryGeneratedColumn} from "typeorm"; + +@Entity({name: 'service_accounts'}) +export class ServiceAccount extends BaseEntity { + + @PrimaryGeneratedColumn() + public id: number; + + @Column({type: Number}) + public owner_id: number; + + @Column({type: Number}) + public service_user_id: number; + + @Column({type: String}) + public description: string; + + @Column({type: Date, nullable: false}) + public endOfLife: string; +} diff --git a/app/gen-server/entity/User.ts b/app/gen-server/entity/User.ts index b4a9909033..cf2dc21b9d 100644 --- a/app/gen-server/entity/User.ts +++ b/app/gen-server/entity/User.ts @@ -1,4 +1,5 @@ import {UserOptions} from 'app/common/UserAPI'; +import {UserTypesStrings} from 'app/common/User'; import {nativeValues} from 'app/gen-server/lib/values'; import {makeId} from 'app/server/lib/idUtils'; import {BaseEntity, BeforeInsert, Column, Entity, JoinTable, ManyToMany, OneToMany, OneToOne, @@ -8,6 +9,7 @@ import {Group} from "./Group"; import {Login} from "./Login"; import {Organization} from "./Organization"; import {Pref} from './Pref'; +import {ServiceAccount} from './ServiceAccount'; @Entity({name: 'users'}) export class User extends BaseEntity { @@ -58,12 +60,22 @@ export class User extends BaseEntity { @Column({name: 'connect_id', type: String, nullable: true}) public connectId: string | null; + @OneToMany(type => User, user => user.serviceAccounts) + @JoinTable({ + name: 'service_account_user', + joinColumn: {name: 'user_id'}, + inverseJoinColumn: {name: 'service_account_id'} + }) + public serviceAccounts: ServiceAccount[]; /** * Unique reference for this user. Primarily used as an ownership key in a cell metadata (comments). */ @Column({name: 'ref', type: String, nullable: false}) public ref: string; + @Column({name: 'type', type: String, default: 'login'}) + public type: UserTypesStrings | null; + @BeforeInsert() public async beforeInsert() { if (!this.ref) { diff --git a/app/gen-server/lib/homedb/HomeDBManager.ts b/app/gen-server/lib/homedb/HomeDBManager.ts index d4593ebda4..20b36280cd 100644 --- a/app/gen-server/lib/homedb/HomeDBManager.ts +++ b/app/gen-server/lib/homedb/HomeDBManager.ts @@ -89,6 +89,7 @@ import { } from "typeorm"; import {v4 as uuidv4} from "uuid"; import { GroupsManager } from './GroupsManager'; +import { ServiceAccountsManager } from './ServiceAccountsManager'; // Support transactions in Sqlite in async code. This is a monkey patch, affecting // the prototypes of various TypeORM classes. @@ -254,6 +255,7 @@ export type BillingOptions = Partial { + //TODO create new service user in order to have its + //id to insert + const uuid = uuidv4(); + const email = `${uuid}@serviceaccounts.local`; + const serviceUser = await this._homeDb.getUserByLogin(email); + // FIXME use manager.save(entité); + return await manager.createQueryBuilder() + .insert() + .into('service_accounts') + .values({ + ownerId, + serviceUserId: serviceUser.id, + description, + endOfLife, + }) + .execute(); + }); + } +} diff --git a/app/gen-server/lib/homedb/UsersManager.ts b/app/gen-server/lib/homedb/UsersManager.ts index 6a0f431c84..7472152409 100644 --- a/app/gen-server/lib/homedb/UsersManager.ts +++ b/app/gen-server/lib/homedb/UsersManager.ts @@ -27,6 +27,8 @@ import { Pref } from 'app/gen-server/entity/Pref'; import flatten from 'lodash/flatten'; import { EntityManager } from 'typeorm'; +import { UserTypesStrings } from 'app/common/User'; + // A special user allowed to add/remove the EVERYONE_EMAIL to/from a resource. export const SUPPORT_EMAIL = appSettings.section('access').flag('supportEmail').requireString({ envVar: 'GRIST_SUPPORT_EMAIL', @@ -371,7 +373,7 @@ export class UsersManager { * unset/outdated fields of an existing record. * */ - public async getUserByLogin(email: string, options: GetUserOptions = {}) { + public async getUserByLogin(email: string, options: GetUserOptions = {}, type?: UserTypesStrings) { const {manager: transaction, profile, userOptions} = options; const normalizedEmail = normalizeEmail(email); return await this._runInTransaction(transaction, async manager => { @@ -389,6 +391,8 @@ export class UsersManager { // Special users do not have first time user set so that they don't get redirected to the // welcome page. user.isFirstTimeUser = !NON_LOGIN_EMAILS.includes(normalizedEmail); + // redémarrer lundi ici TODO + user.type = typeof type === 'undefined' ? 'login' : type; login = new Login(); login.email = normalizedEmail; login.user = user; diff --git a/app/gen-server/migration/1730215435023-ServiceAccounts.ts b/app/gen-server/migration/1730215435023-ServiceAccounts.ts new file mode 100644 index 0000000000..eb667f7281 --- /dev/null +++ b/app/gen-server/migration/1730215435023-ServiceAccounts.ts @@ -0,0 +1,76 @@ +import { MigrationInterface, QueryRunner, Table, TableColumn, TableForeignKey, TableIndex } from "typeorm"; +import * as sqlUtils from "app/gen-server/sqlUtils"; + +export class ServiceAccounts1730215435023 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + const dbType = queryRunner.connection.driver.options.type; + const datetime = sqlUtils.datetime(dbType); + + await queryRunner.addColumn('users', new TableColumn({ + name: 'type', + type: 'varchar', + isNullable: false, + default: "'login'", + })); + await queryRunner.createTable( + new Table({ + name: 'service_accounts', + columns: [ + { + name: 'id', + type: 'int', + isPrimary: true, + }, + { + name: 'owner_id', + type: 'int', + }, + { + name: 'service_user_id', + type: 'int', + }, + { + name: 'description', + type: 'varchar', + }, + { + name: 'endOfLife', + type: datetime, + isNullable: false, + }, + ], + }) + ); + await queryRunner.createForeignKey( + 'service_accounts', + new TableForeignKey({ + columnNames: ['service_user_id'], + referencedColumnNames: ['id'], + referencedTableName: 'users', + onDelete: 'CASCADE', + }) + ); + await queryRunner.createForeignKey( + 'service_accounts', + new TableForeignKey({ + columnNames: ['owner_id'], + referencedColumnNames: ['id'], + referencedTableName: 'users', + onDelete: 'CASCADE', + }) + ); + await queryRunner.createIndex( + 'service_accounts', + new TableIndex({ + name: 'service_account__owner', + columnNames: ['service_accounts_owner', 'user_id'], + }) + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('users', 'type'); + await queryRunner.dropTable('service_accounts'); + } +} diff --git a/test/gen-server/ApiServer.ts b/test/gen-server/ApiServer.ts index e9928e0950..9f291f4610 100644 --- a/test/gen-server/ApiServer.ts +++ b/test/gen-server/ApiServer.ts @@ -2351,6 +2351,106 @@ describe('ApiServer', function() { // Assert that the response status is 404 because the templates org doesn't exist. assert.equal(resp.status, 404); }); + + describe('Service Accounts', function() { + let oldEnv: testUtils.EnvironmentSnapshot; + + testUtils.setTmpLogLevel('error'); + + before(async function() { + oldEnv = new testUtils.EnvironmentSnapshot(); + process.env.GRIST_TEMPLATE_ORG = 'templates'; + server = new TestServer(this); + homeUrl = await server.start(['home', 'docs']); + dbManager = server.dbManager; + + chimpyRef = await dbManager.getUserByLogin(chimpyEmail).then((user) => user.ref); + kiwiRef = await dbManager.getUserByLogin(kiwiEmail).then((user) => user.ref); + charonRef = await dbManager.getUserByLogin(charonEmail).then((user) => user.ref); + + // Listen to user count updates and add them to an array. + dbManager.on('userChange', ({org, countBefore, countAfter}: UserChange) => { + if (countBefore === countAfter) { return; } + userCountUpdates[org.id] = userCountUpdates[org.id] || []; + userCountUpdates[org.id].push(countAfter); + }); + }); + + afterEach(async function() { + oldEnv.restore(); + await dbManager.deleteAllServiceAccounts(); + }); + + after(async function() { + await server.stop(); + }); + + it('Endpoint POST /api/service-accounts is operational', async function() { + + }); + + it('Endpoint POST /api/service-accounts returns 400 when missing parameter in request body', async function() { + + }); + + it('Endpoint POST /api/service-accounts returns 400 on invalid endOfLife', async function() { + + }); + + it('Endpoint GET /api/service-accounts is operational', async function() { + + }); + + it("Endpoint GET /api/service-accounts returns 404 when user don't own any service account", async function() { + + }); + + it('Endpoint GET /api/service-accounts/{saId} is operational', async function() { + + }); + + it('Endpoint GET /api/service-accounts/{saId} returns 404 on non-existing {saId}', async function() { + + }); + + it('Endpoint UPDATE /api/service-accounts/{saId} is operational', async function() { + + }); + + it('Endpoint UPDATE /api/service-accounts/{saId} returns 404 on non-existing {saId}', async function() { + + }); + + it('Endpoint UPDATE /api/service-accounts/{saId} returns 400 on empty label', async function() { + + }); + + // What are we doing about empty description ? + + it('Endpoint UPDATE /api/service-accounts/{saId} returns 400 on invalid endOfLife', async function() { + + }); + + it('Endpoint UPDATE /api/service-accounts/{saId} returns 400 if trying to update owner', async function() { + + }); + + it('Endpoint DELETE /api/service-accounts/{saId} is operational', async function() { + + }); + + it('Endpoint DELETE /api/service-accounts/{saId} returns 404 on non-existing {saId}', async function() { + + }); + + // it('Endpoint UPDATE /api/service-accounts/{saId}/transfer-to/{userId}', async function() { + + // }); + + // it('MUSN'T connect as a login user, async function() { + + //}); + }); }); diff --git a/test/gen-server/migrations.ts b/test/gen-server/migrations.ts index 2795fd2529..cf549d247e 100644 --- a/test/gen-server/migrations.ts +++ b/test/gen-server/migrations.ts @@ -49,6 +49,7 @@ import {ActivationEnabled1722529827161 import {Configs1727747249153 as Configs} from 'app/gen-server/migration/1727747249153-Configs'; import {LoginsEmailsIndex1729754662550 as LoginsEmailsIndex} from 'app/gen-server/migration/1729754662550-LoginsEmailIndex'; +import {ServiceAccounts1730215435023 as ServiceAccounts} from 'app/gen-server/migration/1730215435023-ServiceAccounts'; const home: HomeDBManager = new HomeDBManager(); @@ -58,7 +59,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, Configs, LoginsEmailsIndex]; + UserLastConnection, ActivationEnabled, Configs, LoginsEmailsIndex, ServiceAccounts]; // Assert that the "members" acl rule and group exist (or not). function assertMembersGroup(org: Organization, exists: boolean) {