Skip to content

Commit 07e013e

Browse files
geroplroboquat
authored andcommitted
[server, db] AuthProviderEntry: Introduce oauthRevision to avoid repeated materialization of encrypted data
1 parent 80d7969 commit 07e013e

File tree

10 files changed

+241
-47
lines changed

10 files changed

+241
-47
lines changed

components/dashboard/src/service/service-mock.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ const gitpodServiceMock = createServiceMock({
192192
"clientId": "clientid-123",
193193
"clientSecret": "redacted"
194194
},
195+
"oauthRevision": "some-revision",
195196
"deleted": false
196197
}]
197198
},

components/gitpod-db/BUILD.yaml

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,8 @@ packages:
1616
- name: migrations
1717
type: yarn
1818
srcs:
19-
- "src/typeorm/migration/**/*.ts"
20-
- "src/typeorm/migrate-migrations-0_2_0.ts"
21-
- "src/typeorm/entity/*.ts"
22-
- "src/typeorm/ormconfig.ts"
23-
- "src/typeorm/typeorm.ts"
24-
- "src/typeorm/naming-strategy.ts"
25-
- "src/typeorm/user-db-impl.ts"
26-
- "src/typeorm/transformer.ts"
27-
- "src/config.ts"
28-
- "src/wait-for-db.ts"
29-
- "src/migrate-migrations.ts"
30-
- "src/user-db.ts"
31-
- "package.json"
19+
- "src/**/*.ts"
20+
- package.json
3221
deps:
3322
- components/gitpod-protocol:lib
3423
config:
@@ -64,6 +53,7 @@ packages:
6453
- DB_PORT=23306
6554
- DB_USER=root
6655
- DB_PASSWORD=test
56+
- DB_ENCRYPTION_KEYS=[{"name":"general","version":1,"primary":true,"material":"5vRrp0H4oRgdkPnX1qQcS54Q0xggr6iyho42IQ1rO+c="}]
6757
ephemeral: true
6858
config:
6959
commands:

components/gitpod-db/src/auth-provider-entry-db.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,21 @@
55
*/
66

77
import { AuthProviderEntry as AuthProviderEntry } from "@gitpod/gitpod-protocol";
8+
import { createHash } from "crypto";
89

910
export const AuthProviderEntryDB = Symbol('AuthProviderEntryDB');
1011

1112
export interface AuthProviderEntryDB {
12-
storeAuthProvider(ap: AuthProviderEntry): Promise<AuthProviderEntry>;
13+
storeAuthProvider(ap: AuthProviderEntry, updateOAuthRevision: boolean): Promise<AuthProviderEntry>;
1314

1415
delete(ap: AuthProviderEntry): Promise<void>;
1516

16-
findAll(): Promise<AuthProviderEntry[]>;
17+
findAll(exceptOAuthRevisions?: string[]): Promise<AuthProviderEntry[]>;
18+
findAllHosts(): Promise<string[]>;
1719
findByHost(host: string): Promise<AuthProviderEntry | undefined>;
1820
findByUserId(userId: string): Promise<AuthProviderEntry[]>;
1921
}
22+
23+
export function hashOAuth(oauth: AuthProviderEntry["oauth"]): string {
24+
return createHash('sha256').update(JSON.stringify(oauth)).digest('hex');
25+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the Gitpod Enterprise Source Code License,
4+
* See License.enterprise.txt in the project root folder.
5+
*/
6+
7+
import * as chai from 'chai';
8+
import { suite, test, timeout } from 'mocha-typescript';
9+
import { testContainer } from './test-container';
10+
import { TypeORM } from './typeorm/typeorm';
11+
import { AuthProviderEntryDB } from '.';
12+
import { DBAuthProviderEntry } from './typeorm/entity/db-auth-provider-entry';
13+
import { DeepPartial } from '@gitpod/gitpod-protocol/lib/util/deep-partial';
14+
const expect = chai.expect;
15+
16+
@suite @timeout(5000)
17+
export class AuthProviderEntryDBSpec {
18+
19+
typeORM = testContainer.get<TypeORM>(TypeORM);
20+
db = testContainer.get<AuthProviderEntryDB>(AuthProviderEntryDB);
21+
22+
async before() {
23+
await this.clear();
24+
}
25+
26+
async after() {
27+
await this.clear();
28+
}
29+
30+
protected async clear() {
31+
const connection = await this.typeORM.getConnection();
32+
const manager = connection.manager;
33+
await manager.clear(DBAuthProviderEntry);
34+
}
35+
36+
protected authProvider(ap: DeepPartial<DBAuthProviderEntry> = {}): DBAuthProviderEntry {
37+
const ownerId = "1234";
38+
const host = "github.com";
39+
return {
40+
id: "0049b9d2-005f-43c2-a0ae-76377805d8b8",
41+
host,
42+
ownerId,
43+
status: 'verified',
44+
type: "GitHub",
45+
oauthRevision: undefined,
46+
deleted: false,
47+
...ap,
48+
oauth: {
49+
callBackUrl: "example.org/some/callback",
50+
authorizationUrl: "example.org/some/auth",
51+
settingsUrl: "example.org/settings",
52+
configURL: "example.org/config",
53+
clientId: "clientId",
54+
clientSecret: "clientSecret",
55+
tokenUrl: "example.org/get/token",
56+
scope: "scope",
57+
scopeSeparator: ",",
58+
...ap.oauth,
59+
authorizationParams: {},
60+
},
61+
};
62+
}
63+
64+
@test public async storeEmtpyOAuthRevision() {
65+
const ap = this.authProvider();
66+
await this.db.storeAuthProvider(ap, false);
67+
68+
const aap = await this.db.findByHost(ap.host);
69+
expect(aap, "AuthProvider").to.deep.equal(ap);
70+
}
71+
72+
@test public async findAll() {
73+
const ap1 = this.authProvider({ id: "1", oauthRevision: "rev1" });
74+
const ap2 = this.authProvider({ id: "2", oauthRevision: "rev2" });
75+
await this.db.storeAuthProvider(ap1, false);
76+
await this.db.storeAuthProvider(ap2, false);
77+
78+
const all = await this.db.findAll();
79+
expect(all, "findAll([])").to.deep.equal([ap1, ap2]);
80+
expect(await this.db.findAll([ap1.oauthRevision!, ap2.oauthRevision!]), "findAll([ap1, ap2])").to.be.empty;
81+
expect(await this.db.findAll([ap1.oauthRevision!]), "findAll([ap1])").to.deep.equal([ap2]);
82+
}
83+
84+
@test public async oauthRevision() {
85+
const ap = this.authProvider({ id: "1" });
86+
await this.db.storeAuthProvider(ap, true);
87+
88+
const loadedAp = await this.db.findByHost(ap.host);
89+
expect(loadedAp, "findByHost()").to.deep.equal(ap);
90+
expect(loadedAp?.oauthRevision, "findByHost()").to.equal("e05ea6fab8efcaba4b3246c2b2d3931af897c3bc2c1cf075c31614f0954f9840");
91+
}
92+
}
93+
94+
module.exports = AuthProviderEntryDBSpec

components/gitpod-db/src/typeorm/auth-provider-entry-db-impl.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { AuthProviderEntry } from "@gitpod/gitpod-protocol";
1111
import { AuthProviderEntryDB } from "../auth-provider-entry-db";
1212
import { DBAuthProviderEntry } from "./entity/db-auth-provider-entry";
1313
import { DBIdentity } from "./entity/db-identity";
14+
import { createHash } from "crypto";
1415

1516
@injectable()
1617
export class AuthProviderEntryDBImpl implements AuthProviderEntryDB {
@@ -28,8 +29,11 @@ export class AuthProviderEntryDBImpl implements AuthProviderEntryDB {
2829
return (await this.getEntityManager()).getRepository<DBIdentity>(DBIdentity);
2930
}
3031

31-
async storeAuthProvider(ap: AuthProviderEntry): Promise<AuthProviderEntry> {
32+
async storeAuthProvider(ap: AuthProviderEntry, updateOAuthRevision: boolean): Promise<AuthProviderEntry> {
3233
const repo = await this.getAuthProviderRepo();
34+
if (updateOAuthRevision) {
35+
(ap.oauthRevision as any) = this.oauthContentHash(ap.oauth);
36+
}
3337
return repo.save(ap);
3438
}
3539

@@ -45,13 +49,29 @@ export class AuthProviderEntryDBImpl implements AuthProviderEntryDB {
4549
await repo.update({ id }, { deleted: true });
4650
}
4751

48-
async findAll(): Promise<AuthProviderEntry[]> {
52+
async findAll(exceptOAuthRevisions: string[] = []): Promise<AuthProviderEntry[]> {
53+
exceptOAuthRevisions = exceptOAuthRevisions.filter(r => r !== ""); // never filter out '' which means "undefined" in the DB
54+
4955
const repo = await this.getAuthProviderRepo();
50-
const query = repo.createQueryBuilder('auth_provider')
56+
let query = repo.createQueryBuilder('auth_provider')
5157
.where('auth_provider.deleted != true');
58+
if (exceptOAuthRevisions.length > 0) {
59+
query = query.andWhere('auth_provider.oauthRevision NOT IN (:...exceptOAuthRevisions)', { exceptOAuthRevisions });
60+
}
5261
return query.getMany();
5362
}
5463

64+
async findAllHosts(): Promise<string[]> {
65+
const hostField: keyof DBAuthProviderEntry = "host";
66+
67+
const repo = await this.getAuthProviderRepo();
68+
const query = repo.createQueryBuilder('auth_provider')
69+
.select(hostField)
70+
.where('auth_provider.deleted != true');
71+
const result = (await query.execute()) as Pick<DBAuthProviderEntry, "host">[];
72+
return result.map(r => r.host);
73+
}
74+
5575
async findByHost(host: string): Promise<AuthProviderEntry | undefined> {
5676
const repo = await this.getAuthProviderRepo();
5777
const query = repo.createQueryBuilder('auth_provider')
@@ -68,4 +88,8 @@ export class AuthProviderEntryDBImpl implements AuthProviderEntryDB {
6888
return query.getMany();
6989
}
7090

91+
protected oauthContentHash(oauth: AuthProviderEntry["oauth"]): string {
92+
const result = createHash('sha256').update(JSON.stringify(oauth)).digest('hex');
93+
return result;
94+
}
7195
}

components/gitpod-db/src/typeorm/entity/db-auth-provider-entry.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* See License-AGPL.txt in the project root for license information.
55
*/
66

7-
import { PrimaryColumn, Column, Entity } from "typeorm";
7+
import { PrimaryColumn, Column, Entity, Index } from "typeorm";
88
import { TypeORM } from "../typeorm";
99
import { AuthProviderEntry, OAuth2Config } from "@gitpod/gitpod-protocol";
1010
import { Transformer } from "../transformer";
@@ -37,6 +37,13 @@ export class DBAuthProviderEntry implements AuthProviderEntry {
3737
})
3838
oauth: OAuth2Config;
3939

40+
@Index("ind_oauthRevision")
41+
@Column({
42+
default: '',
43+
transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED,
44+
})
45+
oauthRevision?: string;
46+
4047
@Column()
4148
deleted?: boolean;
4249
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Copyright (c) 2021 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { AuthProviderEntry } from "@gitpod/gitpod-protocol";
8+
import { MigrationInterface, QueryRunner } from "typeorm";
9+
import { dbContainerModule } from "../../container-module";
10+
import { columnExists, indexExists } from "./helper/helper";
11+
import { Container } from 'inversify';
12+
import { AuthProviderEntryDB } from "../../auth-provider-entry-db";
13+
import { UserDB } from "../../user-db";
14+
15+
const TABLE_NAME = "d_b_auth_provider_entry";
16+
const COLUMN_NAME: keyof AuthProviderEntry = "oauthRevision";
17+
const INDEX_NAME = "ind_oauthRevision";
18+
19+
export class OAuthRevision1643986994402 implements MigrationInterface {
20+
21+
public async up(queryRunner: QueryRunner): Promise<void> {
22+
// create new column
23+
if (!(await columnExists(queryRunner, TABLE_NAME, COLUMN_NAME))) {
24+
await queryRunner.query(`ALTER TABLE ${TABLE_NAME} ADD COLUMN ${COLUMN_NAME} varchar(128) NOT NULL DEFAULT ''`);
25+
}
26+
27+
// create index on said column
28+
if (!(await indexExists(queryRunner, TABLE_NAME, INDEX_NAME))) {
29+
await queryRunner.query(`CREATE INDEX ${INDEX_NAME} ON ${TABLE_NAME} (${COLUMN_NAME})`);
30+
}
31+
32+
// to update all oauthRevisions we need to load all providers (to decrypt them) and
33+
// write them back using the DB implementation (which does the calculation for us)
34+
const container = new Container();
35+
container.load(dbContainerModule);
36+
37+
container.get<UserDB>(UserDB); // initializes encryptionProvider as side effect
38+
const db = container.get<AuthProviderEntryDB>(AuthProviderEntryDB);
39+
const allProviders = await db.findAll([]);
40+
const writes: Promise<AuthProviderEntry>[] = [];
41+
for (const provider of allProviders) {
42+
writes.push(db.storeAuthProvider(provider, true));
43+
}
44+
await Promise.all(writes);
45+
}
46+
47+
public async down(queryRunner: QueryRunner): Promise<void> {
48+
await queryRunner.query(`ALTER TABLE ${TABLE_NAME} DROP INDEX ${INDEX_NAME}`);
49+
await queryRunner.query(`ALTER TABLE ${TABLE_NAME} DROP COLUMN ${COLUMN_NAME}`);
50+
}
51+
52+
}

components/gitpod-protocol/src/protocol.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1171,6 +1171,8 @@ export interface AuthProviderEntry {
11711171
readonly status: AuthProviderEntry.Status;
11721172

11731173
readonly oauth: OAuth2Config;
1174+
/** A random string that is to change whenever oauth changes (enforced on DB level) */
1175+
readonly oauthRevision?: string;
11741176
}
11751177

11761178
export interface OAuth2Config {

components/server/src/auth/auth-provider-service.ts

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ export class AuthProviderService {
2626
/**
2727
* Returns all auth providers.
2828
*/
29-
async getAllAuthProviders(): Promise<AuthProviderParams[]> {
30-
const all = await this.authProviderDB.findAll();
29+
async getAllAuthProviders(exceptOAuthRevisions: string[] = []): Promise<AuthProviderParams[]> {
30+
const all = await this.authProviderDB.findAll(exceptOAuthRevisions);
3131
const transformed = all.map(this.toAuthProviderParams.bind(this));
3232

3333
// as a precaution, let's remove duplicates
@@ -43,6 +43,10 @@ export class AuthProviderService {
4343
return Array.from(unique.values());
4444
}
4545

46+
async getAllAuthProviderHosts(): Promise<string[]> {
47+
return this.authProviderDB.findAllHosts();
48+
}
49+
4650
protected toAuthProviderParams = (oap: AuthProviderEntry) => <AuthProviderParams>{
4751
...oap,
4852
host: oap.host.toLowerCase(),
@@ -82,13 +86,14 @@ export class AuthProviderService {
8286
}
8387

8488
// update config on demand
89+
const oauth = {
90+
...existing.oauth,
91+
clientId: entry.clientId,
92+
clientSecret: entry.clientSecret || existing.oauth.clientSecret, // FE may send empty ("") if not changed
93+
};
8594
authProvider = {
8695
...existing,
87-
oauth: {
88-
...existing.oauth,
89-
clientId: entry.clientId,
90-
clientSecret: entry.clientSecret || existing.oauth.clientSecret, // FE may send empty ("") if not changed
91-
},
96+
oauth,
9297
status: "pending",
9398
}
9499
} else {
@@ -98,24 +103,25 @@ export class AuthProviderService {
98103
}
99104
authProvider = this.initializeNewProvider(entry);
100105
}
101-
return await this.authProviderDB.storeAuthProvider(authProvider as AuthProviderEntry);
106+
return await this.authProviderDB.storeAuthProvider(authProvider as AuthProviderEntry, true);
102107
}
103108
protected initializeNewProvider(newEntry: AuthProviderEntry.NewEntry): AuthProviderEntry {
104109
const { host, type, clientId, clientSecret } = newEntry;
105110
const urls = type === "GitHub" ? githubUrls(host) : (type === "GitLab" ? gitlabUrls(host) : undefined);
106111
if (!urls) {
107112
throw new Error("Unexpected service type.");
108113
}
109-
return <AuthProviderEntry>{
114+
const oauth: AuthProviderEntry["oauth"] = {
115+
...urls,
116+
callBackUrl: this.callbackUrl(host),
117+
clientId: clientId!,
118+
clientSecret: clientSecret!,
119+
};
120+
return {
110121
...newEntry,
111122
id: uuidv4(),
112123
type,
113-
oauth: {
114-
...urls,
115-
callBackUrl: this.callbackUrl(host),
116-
clientId,
117-
clientSecret,
118-
},
124+
oauth,
119125
status: "pending",
120126
};
121127
}
@@ -136,7 +142,7 @@ export class AuthProviderService {
136142
ownerId: ownerId,
137143
status: "verified"
138144
};
139-
await this.authProviderDB.storeAuthProvider(ap);
145+
await this.authProviderDB.storeAuthProvider(ap, true);
140146
} else {
141147
log.warn("Failed to find the AuthProviderEntry to be activated.", { params, id, ap });
142148
}

0 commit comments

Comments
 (0)