From 3a07121763198c3ddef6c1e315c5535bf8737f78 Mon Sep 17 00:00:00 2001 From: Sven Efftinge Date: Thu, 7 Sep 2023 06:11:03 +0200 Subject: [PATCH] [fga] add relationship update job (#18671) --- .../authorization/relationship-updater-job.ts | 54 +++++++++++++++++++ .../relationship-updater.spec.db.ts | 2 +- .../src/authorization/relationship-updater.ts | 20 ++++--- components/server/src/container-module.ts | 2 + components/server/src/jobs/runner.ts | 20 ++++--- components/usage-api/typescript/tsconfig.json | 1 + 6 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 components/server/src/authorization/relationship-updater-job.ts diff --git a/components/server/src/authorization/relationship-updater-job.ts b/components/server/src/authorization/relationship-updater-job.ts new file mode 100644 index 00000000000000..192f5413ad6705 --- /dev/null +++ b/components/server/src/authorization/relationship-updater-job.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2023 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import { injectable, inject } from "inversify"; +import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; +import { Job } from "../jobs/runner"; +import { RelationshipUpdater } from "./relationship-updater"; +import { TypeORM, UserDB } from "@gitpod/gitpod-db/lib"; + +@injectable() +export class RelationshipUpdateJob implements Job { + constructor( + @inject(RelationshipUpdater) private relationshipUpdater: RelationshipUpdater, + @inject(UserDB) private readonly userDB: UserDB, + @inject(TypeORM) private readonly db: TypeORM, + ) {} + + public name = "relationship-update-job"; + public frequencyMs = 1000 * 60 * 60 * 1; // 1h + + public async run(): Promise { + try { + const connection = await this.db.getConnection(); + const results = await connection.query(` + SELECT id FROM d_b_user + WHERE + (additionalData->"$.fgaRelationshipsVersion" != ${RelationshipUpdater.version} OR + additionalData->"$.fgaRelationshipsVersion" IS NULL) AND + markedDeleted = 0 + ORDER BY _lastModified DESC + LIMIT 1000;`); + const now = Date.now(); + for (const result of results) { + const user = await this.userDB.findUserById(result.id); + if (!user) { + continue; + } + try { + await this.relationshipUpdater.migrate(user); + } catch (error) { + log.error(RelationshipUpdateJob.name + ": error running relationship update job", error); + } + } + log.info( + RelationshipUpdateJob.name + ": updated " + results.length + " users in " + (Date.now() - now) + "ms", + ); + } catch (error) { + log.error(RelationshipUpdateJob.name + ": error running relationship update job", error); + } + } +} diff --git a/components/server/src/authorization/relationship-updater.spec.db.ts b/components/server/src/authorization/relationship-updater.spec.db.ts index 70e86fc220a6bc..b45c6c28435151 100644 --- a/components/server/src/authorization/relationship-updater.spec.db.ts +++ b/components/server/src/authorization/relationship-updater.spec.db.ts @@ -337,7 +337,7 @@ describe("RelationshipUpdater", async () => { AdditionalUserData.set(user, { fgaRelationshipsVersion: undefined }); user = await userDB.storeUser(user); user = await migrator.migrate(user); - expect(user.additionalData?.fgaRelationshipsVersion).to.equal(migrator.version); + expect(user.additionalData?.fgaRelationshipsVersion).to.equal(RelationshipUpdater.version); return user; } }); diff --git a/components/server/src/authorization/relationship-updater.ts b/components/server/src/authorization/relationship-updater.ts index c1e0dbbdb1bacf..079a1ee4762d61 100644 --- a/components/server/src/authorization/relationship-updater.ts +++ b/components/server/src/authorization/relationship-updater.ts @@ -17,7 +17,7 @@ import { RedisMutex } from "../redis/mutex"; @injectable() export class RelationshipUpdater { - public readonly version = 2; + public static readonly version = 2; constructor( @inject(UserDB) private readonly userDB: UserDB, @@ -39,12 +39,20 @@ export class RelationshipUpdater { * @returns */ public async migrate(user: User): Promise { - const fgaEnabled = await getExperimentsClientForBackend().getValueAsync("centralizedPermissions", false, { + let isEnabled = await getExperimentsClientForBackend().getValueAsync("spicedb_relationship_updates", false, { user: { id: user.id, }, }); - if (!fgaEnabled) { + if (!isEnabled) { + // check the centralizedPermission featureflag + isEnabled = await getExperimentsClientForBackend().getValueAsync("centralizedPermissions", false, { + user: { + id: user.id, + }, + }); + } + if (!isEnabled) { if (user.additionalData?.fgaRelationshipsVersion !== undefined) { log.info({ userId: user.id }, `User has been removed from FGA.`); // reset the fgaRelationshipsVersion to undefined, so the migration is triggered again when the feature is enabled @@ -71,7 +79,7 @@ export class RelationshipUpdater { } log.info({ userId: user.id }, `Updating FGA relationships for user.`, { fromVersion: user?.additionalData?.fgaRelationshipsVersion, - toVersion: this.version, + toVersion: RelationshipUpdater.version, }); const orgs = await this.findAffectedOrganizations(user.id); @@ -82,7 +90,7 @@ export class RelationshipUpdater { } await this.updateWorkspaces(user); AdditionalUserData.set(user, { - fgaRelationshipsVersion: this.version, + fgaRelationshipsVersion: RelationshipUpdater.version, }); await this.userDB.updateUserPartial({ id: user.id, @@ -99,7 +107,7 @@ export class RelationshipUpdater { } private isMigrated(user: User) { - return user.additionalData?.fgaRelationshipsVersion === this.version; + return user.additionalData?.fgaRelationshipsVersion === RelationshipUpdater.version; } private async findAffectedOrganizations(userId: string): Promise { diff --git a/components/server/src/container-module.ts b/components/server/src/container-module.ts index 7e73577ea5ba2a..83880652fc6fde 100644 --- a/components/server/src/container-module.ts +++ b/components/server/src/container-module.ts @@ -132,6 +132,7 @@ import { SSHKeyService } from "./user/sshkey-service"; import { GitpodTokenService } from "./user/gitpod-token-service"; import { EnvVarService } from "./user/env-var-service"; import { ScmService } from "./projects/scm-service"; +import { RelationshipUpdateJob } from "./authorization/relationship-updater-job"; export const productionContainerModule = new ContainerModule( (bind, unbind, isBound, rebind, unbindAsync, onActivation, onDeactivation) => { @@ -368,6 +369,7 @@ export const productionContainerModule = new ContainerModule( bind(OTSGarbageCollector).toSelf().inSingletonScope(); bind(SnapshotsJob).toSelf().inSingletonScope(); bind(JobRunner).toSelf().inSingletonScope(); + bind(RelationshipUpdateJob).toSelf().inSingletonScope(); // Redis bind(Redis).toDynamicValue((ctx) => { diff --git a/components/server/src/jobs/runner.ts b/components/server/src/jobs/runner.ts index 4e58773a21f86b..dfcc27ed5afedd 100644 --- a/components/server/src/jobs/runner.ts +++ b/components/server/src/jobs/runner.ts @@ -17,6 +17,7 @@ import { TokenGarbageCollector } from "./token-gc"; import { WebhookEventGarbageCollector } from "./webhook-gc"; import { WorkspaceGarbageCollector } from "./workspace-gc"; import { SnapshotsJob } from "./snapshots"; +import { RelationshipUpdateJob } from "../authorization/relationship-updater-job"; export const Job = Symbol("Job"); @@ -29,14 +30,16 @@ export interface Job { @injectable() export class JobRunner { - @inject(RedisMutex) protected mutex: RedisMutex; - - @inject(DatabaseGarbageCollector) protected databaseGC: DatabaseGarbageCollector; - @inject(OTSGarbageCollector) protected otsGC: OTSGarbageCollector; - @inject(TokenGarbageCollector) protected tokenGC: TokenGarbageCollector; - @inject(WebhookEventGarbageCollector) protected webhookGC: WebhookEventGarbageCollector; - @inject(WorkspaceGarbageCollector) protected workspaceGC: WorkspaceGarbageCollector; - @inject(SnapshotsJob) protected snapshotsJob: SnapshotsJob; + constructor( + @inject(RedisMutex) private readonly mutex: RedisMutex, + @inject(DatabaseGarbageCollector) private readonly databaseGC: DatabaseGarbageCollector, + @inject(OTSGarbageCollector) private readonly otsGC: OTSGarbageCollector, + @inject(TokenGarbageCollector) private readonly tokenGC: TokenGarbageCollector, + @inject(WebhookEventGarbageCollector) private readonly webhookGC: WebhookEventGarbageCollector, + @inject(WorkspaceGarbageCollector) private readonly workspaceGC: WorkspaceGarbageCollector, + @inject(SnapshotsJob) private readonly snapshotsJob: SnapshotsJob, + @inject(RelationshipUpdateJob) private readonly relationshipUpdateJob: RelationshipUpdateJob, + ) {} public start(): DisposableCollection { const disposables = new DisposableCollection(); @@ -48,6 +51,7 @@ export class JobRunner { this.webhookGC, this.workspaceGC, this.snapshotsJob, + this.relationshipUpdateJob, ]; for (const job of jobs) { diff --git a/components/usage-api/typescript/tsconfig.json b/components/usage-api/typescript/tsconfig.json index 0656c4553b236a..871416d97e11bd 100644 --- a/components/usage-api/typescript/tsconfig.json +++ b/components/usage-api/typescript/tsconfig.json @@ -15,6 +15,7 @@ "downlevelIteration": true, "module": "commonjs", "moduleResolution": "node", + "esModuleInterop": true, "target": "es6", "jsx": "react", "sourceMap": true,