From b2d501842f3552502e8be8febae28a730efef1b8 Mon Sep 17 00:00:00 2001 From: Alex Tugarev Date: Fri, 10 Nov 2023 09:25:47 +0100 Subject: [PATCH] [server] move FGA calls into AuthProviderService (#19017) * [server] move FGA calls into AuthProviderService * split internal upsert method `updateAuthProvider` into create and update * refactor: move `getAuthProviders` logic from gitpod-server-impl to auth-provider-service * adding db tests for auth provider server * use redacted results in service * Fix typos * extract helper functions for scopes * add more tests * as regular member, should find org-level providers if no built-in providers present * as regular member, should find only built-in providers if present --------- Co-authored-by: Huiwen --- .../typeorm/entity/db-auth-provider-entry.ts | 6 +- .../gitpod-protocol/src/gitpod-service.ts | 27 +- components/gitpod-protocol/src/protocol.ts | 2 +- .../server/src/auth/auth-provider-scopes.ts | 52 +++ .../src/auth/auth-provider-service.spec.db.ts | 362 ++++++++++++++++++ .../server/src/auth/auth-provider-service.ts | 283 +++++++++++--- components/server/src/auth/auth-provider.ts | 34 +- components/server/src/auth/authenticator.ts | 2 - .../server/src/auth/generic-auth-provider.ts | 2 +- .../src/auth/host-context-provider-impl.ts | 2 +- components/server/src/auth/rate-limiter.ts | 3 + .../server/src/user/user-deletion-service.ts | 2 +- .../src/workspace/gitpod-server-impl.ts | 184 ++++----- 13 files changed, 768 insertions(+), 193 deletions(-) create mode 100644 components/server/src/auth/auth-provider-scopes.ts create mode 100644 components/server/src/auth/auth-provider-service.spec.db.ts diff --git a/components/gitpod-db/src/typeorm/entity/db-auth-provider-entry.ts b/components/gitpod-db/src/typeorm/entity/db-auth-provider-entry.ts index 6f0a4468268b9e..088b8c34b5f832 100644 --- a/components/gitpod-db/src/typeorm/entity/db-auth-provider-entry.ts +++ b/components/gitpod-db/src/typeorm/entity/db-auth-provider-entry.ts @@ -18,7 +18,11 @@ export class DBAuthProviderEntry implements AuthProviderEntry { @Column() ownerId: string; - @Column() + @Column({ + ...TypeORM.UUID_COLUMN_TYPE, + default: "", + transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED, + }) organizationId?: string; @Column("varchar") diff --git a/components/gitpod-protocol/src/gitpod-service.ts b/components/gitpod-protocol/src/gitpod-service.ts index f6a18474884ea9..7c5471b4e0ed92 100644 --- a/components/gitpod-protocol/src/gitpod-service.ts +++ b/components/gitpod-protocol/src/gitpod-service.ts @@ -81,16 +81,31 @@ export interface GitpodServer extends JsonRpcServer, AdminServer, updateLoggedInUser(user: Partial): Promise; sendPhoneNumberVerificationToken(phoneNumber: string): Promise<{ verificationId: string }>; verifyPhoneNumberVerificationToken(phoneNumber: string, token: string, verificationId: string): Promise; - getAuthProviders(): Promise; - getOwnAuthProviders(): Promise; - updateOwnAuthProvider(params: GitpodServer.UpdateOwnAuthProviderParams): Promise; - deleteOwnAuthProvider(params: GitpodServer.DeleteOwnAuthProviderParams): Promise; getConfiguration(): Promise; getToken(query: GitpodServer.GetTokenSearchOptions): Promise; getGitpodTokenScopes(tokenHash: string): Promise; deleteAccount(): Promise; getClientRegion(): Promise; + // Auth Provider API + getAuthProviders(): Promise; + // user-level + getOwnAuthProviders(): Promise; + updateOwnAuthProvider(params: GitpodServer.UpdateOwnAuthProviderParams): Promise; + deleteOwnAuthProvider(params: GitpodServer.DeleteOwnAuthProviderParams): Promise; + // org-level + createOrgAuthProvider(params: GitpodServer.CreateOrgAuthProviderParams): Promise; + updateOrgAuthProvider(params: GitpodServer.UpdateOrgAuthProviderParams): Promise; + getOrgAuthProviders(params: GitpodServer.GetOrgAuthProviderParams): Promise; + deleteOrgAuthProvider(params: GitpodServer.DeleteOrgAuthProviderParams): Promise; + // public-api compatibility + /** @deprecated used for public-api compatibility only */ + getAuthProvider(id: string): Promise; + /** @deprecated used for public-api compatibility only */ + deleteAuthProvider(id: string): Promise; + /** @deprecated used for public-api compatibility only */ + updateAuthProvider(id: string, update: AuthProviderEntry.UpdateOAuth2Config): Promise; + // Query/retrieve workspaces getWorkspaces(options: GitpodServer.GetWorkspacesOptions): Promise; getWorkspaceOwner(workspaceId: string): Promise; @@ -167,10 +182,6 @@ export interface GitpodServer extends JsonRpcServer, AdminServer, deleteTeam(teamId: string): Promise; getOrgSettings(orgId: string): Promise; updateOrgSettings(teamId: string, settings: Partial): Promise; - createOrgAuthProvider(params: GitpodServer.CreateOrgAuthProviderParams): Promise; - updateOrgAuthProvider(params: GitpodServer.UpdateOrgAuthProviderParams): Promise; - getOrgAuthProviders(params: GitpodServer.GetOrgAuthProviderParams): Promise; - deleteOrgAuthProvider(params: GitpodServer.DeleteOrgAuthProviderParams): Promise; getDefaultWorkspaceImage(params: GetDefaultWorkspaceImageParams): Promise; diff --git a/components/gitpod-protocol/src/protocol.ts b/components/gitpod-protocol/src/protocol.ts index af94bcaf3e9104..ef5f62aa4e33b6 100644 --- a/components/gitpod-protocol/src/protocol.ts +++ b/components/gitpod-protocol/src/protocol.ts @@ -1529,7 +1529,6 @@ export interface AuthProviderInfo { readonly ownerId?: string; readonly organizationId?: string; readonly verified: boolean; - readonly isReadonly?: boolean; readonly hiddenOnDashboard?: boolean; readonly disallowLogin?: boolean; readonly icon?: string; @@ -1588,6 +1587,7 @@ export namespace AuthProviderEntry { clientSecret: string; organizationId: string; }; + export type UpdateOAuth2Config = Pick; export function redact(entry: AuthProviderEntry): AuthProviderEntry { return { ...entry, diff --git a/components/server/src/auth/auth-provider-scopes.ts b/components/server/src/auth/auth-provider-scopes.ts new file mode 100644 index 00000000000000..557338b746d5a8 --- /dev/null +++ b/components/server/src/auth/auth-provider-scopes.ts @@ -0,0 +1,52 @@ +/** + * 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 { AuthProviderEntry } from "@gitpod/gitpod-protocol"; +import { GitHubScope } from "../github/scopes"; +import { GitLabScope } from "../gitlab/scopes"; +import { BitbucketOAuthScopes } from "../bitbucket/bitbucket-oauth-scopes"; +import { BitbucketServerOAuthScopes } from "../bitbucket-server/bitbucket-server-oauth-scopes"; + +export function getRequiredScopes(entry: AuthProviderEntry) { + switch (entry.type) { + case "GitHub": + return { + default: GitHubScope.Requirements.DEFAULT, + publicRepo: GitHubScope.Requirements.PUBLIC_REPO, + privateRepo: GitHubScope.Requirements.PRIVATE_REPO, + }; + case "GitLab": + return { + default: GitLabScope.Requirements.DEFAULT, + publicRepo: GitLabScope.Requirements.DEFAULT, + privateRepo: GitLabScope.Requirements.REPO, + }; + case "Bitbucket": + return { + default: BitbucketOAuthScopes.Requirements.DEFAULT, + publicRepo: BitbucketOAuthScopes.Requirements.DEFAULT, + privateRepo: BitbucketOAuthScopes.Requirements.DEFAULT, + }; + case "BitbucketServer": + return { + default: BitbucketServerOAuthScopes.Requirements.DEFAULT, + publicRepo: BitbucketServerOAuthScopes.Requirements.DEFAULT, + privateRepo: BitbucketServerOAuthScopes.Requirements.DEFAULT, + }; + } +} +export function getScopesOfProvider(entry: AuthProviderEntry) { + switch (entry.type) { + case "GitHub": + return GitHubScope.All; + case "GitLab": + return GitLabScope.All; + case "Bitbucket": + return BitbucketOAuthScopes.ALL; + case "BitbucketServer": + return BitbucketServerOAuthScopes.ALL; + } +} diff --git a/components/server/src/auth/auth-provider-service.spec.db.ts b/components/server/src/auth/auth-provider-service.spec.db.ts new file mode 100644 index 00000000000000..eab7ee795271ba --- /dev/null +++ b/components/server/src/auth/auth-provider-service.spec.db.ts @@ -0,0 +1,362 @@ +/** + * 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 { TypeORM } from "@gitpod/gitpod-db/lib"; +import { AuthProviderInfo, Organization, User } from "@gitpod/gitpod-protocol"; +import { Experiments } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server"; +import * as chai from "chai"; +import { Container } from "inversify"; +import "mocha"; +import { createTestContainer } from "../test/service-testing-container-module"; +import { resetDB } from "@gitpod/gitpod-db/lib/test/reset-db"; +import { UserService } from "../user/user-service"; +import { AuthProviderService } from "./auth-provider-service"; +import { Config } from "../config"; +import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; +import { expectError } from "../test/expect-utils"; +import { AuthProviderEntry } from "@gitpod/gitpod-protocol"; +import { AuthProviderParams } from "./auth-provider"; +import { OrganizationService } from "../orgs/organization-service"; + +const expect = chai.expect; + +describe("AuthProviderService", async () => { + let service: AuthProviderService; + let userService: UserService; + let orgService: OrganizationService; + let container: Container; + let currentUser: User; + let org: Organization; + + const newEntry = () => + { + host: "github.com", + ownerId: currentUser.id, + type: "GitHub", + clientId: "123", + clientSecret: "secret-123", + }; + const expectedEntry = () => + >{ + host: "github.com", + oauth: { + authorizationUrl: "https://github.com/login/oauth/authorize", + callBackUrl: "https://gitpod.io/auth/callback", + clientId: "123", + clientSecret: "redacted", + tokenUrl: "https://github.com/login/oauth/access_token", + }, + organizationId: undefined, + type: "GitHub", + status: "pending", + ownerId: currentUser.id, + }; + const expectedParams = () => + >{ + builtin: false, + disallowLogin: false, + verified: false, + ...expectedEntry(), + oauth: { ...expectedEntry().oauth, clientSecret: "secret-123" }, + }; + + const newOrgEntry = () => + { + host: "github.com", + ownerId: currentUser.id, + type: "GitHub", + clientId: "123", + clientSecret: "secret-123", + organizationId: org.id, + }; + const expectedOrgEntry = () => + >{ + host: "github.com", + oauth: { + authorizationUrl: "https://github.com/login/oauth/authorize", + callBackUrl: "https://gitpod.io/auth/callback", + clientId: "123", + clientSecret: "redacted", + tokenUrl: "https://github.com/login/oauth/access_token", + }, + organizationId: org.id, + type: "GitHub", + status: "pending", + ownerId: currentUser.id, + }; + const expectedOrgParams = () => + >{ + builtin: false, + disallowLogin: true, + verified: false, + ...expectedOrgEntry(), + oauth: { ...expectedOrgEntry().oauth, clientSecret: "secret-123" }, + }; + + const addBuiltInProvider = (host: string = "github.com") => { + const config = container.get(Config); + config.builtinAuthProvidersConfigured = true; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + config.authProviderConfigs.push((>{ + host, + id: "Public-GitHub", + verified: true, + }) as any); + }; + + beforeEach(async () => { + container = createTestContainer(); + Experiments.configureTestingClient({ + centralizedPermissions: true, + }); + service = container.get(AuthProviderService); + userService = container.get(UserService); + currentUser = await userService.createUser({ + identity: { + authId: "gh-user-1", + authName: "user", + authProviderId: "public-github", + }, + }); + orgService = container.get(OrganizationService); + org = await orgService.createOrganization(currentUser.id, "myorg"); + }); + + afterEach(async () => { + // Clean-up database + await resetDB(container.get(TypeORM)); + }); + + describe("createAuthProviderOfUser", async () => { + it("should create user-level provider", async () => { + const providersAtStart = await service.getAllAuthProviderParams(); + expect(providersAtStart).to.be.empty; + + await service.createAuthProviderOfUser(currentUser.id, newEntry()); + + const providers = await service.getAllAuthProviderParams(); + expect(providers).to.have.lengthOf(1); + expect(providers[0]).to.deep.include(expectedParams()); + }); + + it("should fail in case of conflict with built-in provider", async () => { + addBuiltInProvider(); + + const providersAtStart = await service.getAllAuthProviderParams(); + expect(providersAtStart).to.be.empty; + + await expectError(ErrorCodes.CONFLICT, service.createAuthProviderOfUser(currentUser.id, newEntry())); + }); + it("should fail if host is not reachable", async () => { + await expectError( + ErrorCodes.BAD_REQUEST, + service.createAuthProviderOfUser(currentUser.id, { + ...newEntry(), + host: "please-dont-register-this-domain.com:666", + }), + ); + }); + it("should fail if trying to register same host", async () => { + const providersAtStart = await service.getAllAuthProviderParams(); + expect(providersAtStart).to.be.empty; + + await service.createAuthProviderOfUser(currentUser.id, newEntry()); + + await expectError(ErrorCodes.CONFLICT, service.createAuthProviderOfUser(currentUser.id, newEntry())); + }); + }); + + describe("createOrgAuthProvider", async () => { + it("should create org-level provider", async () => { + const providersAtStart = await service.getAllAuthProviderParams(); + expect(providersAtStart).to.be.empty; + + await service.createOrgAuthProvider(currentUser.id, newOrgEntry()); + + const providers = await service.getAllAuthProviderParams(); + expect(providers).to.have.lengthOf(1); + expect(providers[0]).to.deep.include(expectedOrgParams()); + }); + it("should fail if host is not reachable", async () => { + await expectError( + ErrorCodes.BAD_REQUEST, + service.createOrgAuthProvider(currentUser.id, { + ...newOrgEntry(), + host: "please-dont-register-this-domain.com:666", + }), + ); + }); + it("should fail if trying to register same host", async () => { + const providersAtStart = await service.getAllAuthProviderParams(); + expect(providersAtStart).to.be.empty; + + await service.createOrgAuthProvider(currentUser.id, newOrgEntry()); + + await expectError(ErrorCodes.CONFLICT, service.createAuthProviderOfUser(currentUser.id, newOrgEntry())); + }); + }); + describe("getAuthProvider", async () => { + it("should find org-level provider", async () => { + const providersAtStart = await service.getAllAuthProviderParams(); + expect(providersAtStart).to.be.empty; + + const created = await service.createOrgAuthProvider(currentUser.id, newOrgEntry()); + + const retrieved = await service.getAuthProvider(currentUser.id, created.id); + expect(retrieved).to.deep.include(expectedOrgEntry()); + }); + it("should find user-level provider", async () => { + const providersAtStart = await service.getAllAuthProviderParams(); + expect(providersAtStart).to.be.empty; + + const created = await service.createAuthProviderOfUser(currentUser.id, newEntry()); + + const retrieved = await service.getAuthProvider(currentUser.id, created.id); + expect(retrieved).to.deep.include(expectedEntry()); + }); + it("should not find org-level provider for non-members", async () => { + const providersAtStart = await service.getAllAuthProviderParams(); + expect(providersAtStart).to.be.empty; + + const created = await service.createOrgAuthProvider(currentUser.id, newOrgEntry()); + + const nonMember = await userService.createUser({ + identity: { + authId: "gh-user-2", + authName: "user2", + authProviderId: "public-github", + }, + }); + + // expecting 404, as Orgs shall not be enumerable to non-members + await expectError(ErrorCodes.NOT_FOUND, service.getAuthProvider(nonMember.id, created.id)); + }); + }); + + describe("getAuthProviderDescriptionsUnauthenticated", async () => { + it("should find built-in provider", async () => { + addBuiltInProvider(); + + const providers = await service.getAuthProviderDescriptionsUnauthenticated(); + expect(providers).to.has.lengthOf(1); + expect(providers[0].authProviderId).to.be.equal("Public-GitHub"); + }); + it("should find only built-in providers but no user-level providers", async () => { + addBuiltInProvider("localhost"); + + const created = await service.createAuthProviderOfUser(currentUser.id, newEntry()); + await service.markAsVerified({ userId: currentUser.id, id: created.id }); + + const providers = await service.getAuthProviderDescriptionsUnauthenticated(); + expect(providers).to.has.lengthOf(1); + expect(providers[0].host).to.be.equal("localhost"); + }); + it("should find user-level providers if no built-in providers present", async () => { + const created = await service.createAuthProviderOfUser(currentUser.id, newEntry()); + await service.markAsVerified({ userId: currentUser.id, id: created.id }); + + const providers = await service.getAuthProviderDescriptionsUnauthenticated(); + expect(providers).to.has.lengthOf(1); + expect(providers[0]).to.deep.include(>{ + authProviderId: created.id, + authProviderType: created.type, + host: created.host, + }); + + const privateProperties: (keyof AuthProviderEntry)[] = ["oauth", "organizationId", "ownerId"]; + for (const privateProperty of privateProperties) { + expect(providers[0]).to.not.haveOwnProperty(privateProperty); + } + }); + }); + + describe("getAuthProviderDescriptions", async () => { + it("should find built-in provider", async () => { + addBuiltInProvider(); + + const providers = await service.getAuthProviderDescriptions(currentUser); + expect(providers).to.has.lengthOf(1); + expect(providers[0].authProviderId).to.be.equal("Public-GitHub"); + }); + it("should find built-in providers and _own_ user-level providers", async () => { + addBuiltInProvider("localhost"); + + const created = await service.createAuthProviderOfUser(currentUser.id, newEntry()); + await service.markAsVerified({ userId: currentUser.id, id: created.id }); + + const providers = await service.getAuthProviderDescriptions(currentUser); + expect(providers).to.has.lengthOf(2); + expect(providers[0].host).to.be.equal(created.host); + expect(providers[1].host).to.be.equal("localhost"); + }); + it("should find user-level providers if no built-in providers present", async () => { + const created = await service.createAuthProviderOfUser(currentUser.id, newEntry()); + await service.markAsVerified({ userId: currentUser.id, id: created.id }); + + const providers = await service.getAuthProviderDescriptions(currentUser); + expect(providers).to.has.lengthOf(1); + expect(providers[0]).to.deep.include(>{ + authProviderId: created.id, + authProviderType: created.type, + host: created.host, + organizationId: created.organizationId, + ownerId: created.ownerId, + }); + + const oauthProperty: keyof AuthProviderEntry = "oauth"; + expect(providers[0]).to.not.haveOwnProperty(oauthProperty); + }); + it.only("as regular member, should find org-level providers if no built-in providers present", async () => { + const member = await userService.createUser({ + identity: { + authId: "gh-user-2", + authName: "user2", + authProviderId: "public-github", + }, + }); + const invite = await orgService.getOrCreateInvite(currentUser.id, org.id); + await orgService.joinOrganization(member.id, invite.id); + + const created = await service.createOrgAuthProvider(currentUser.id, newOrgEntry()); + await service.markAsVerified({ userId: currentUser.id, id: created.id }); + + const providers = await service.getAuthProviderDescriptions(member); + + expect(providers).to.has.lengthOf(1); + expect(providers[0]).to.deep.include(>{ + authProviderId: created.id, + authProviderType: created.type, + host: created.host, + organizationId: created.organizationId, + ownerId: created.ownerId, + }); + + const oauthProperty: keyof AuthProviderEntry = "oauth"; + expect(providers[0]).to.not.haveOwnProperty(oauthProperty); + }); + it.only("as regular member, should find only built-in providers if present", async () => { + addBuiltInProvider("localhost"); + + const member = await userService.createUser({ + identity: { + authId: "gh-user-2", + authName: "user2", + authProviderId: "public-github", + }, + }); + const invite = await orgService.getOrCreateInvite(currentUser.id, org.id); + await orgService.joinOrganization(member.id, invite.id); + + const created = await service.createOrgAuthProvider(currentUser.id, newOrgEntry()); + await service.markAsVerified({ userId: currentUser.id, id: created.id }); + + const providers = await service.getAuthProviderDescriptions(member); + + expect(providers).to.has.lengthOf(1); + expect(providers[0].host).to.be.equal("localhost"); + }); + }); +}); diff --git a/components/server/src/auth/auth-provider-service.ts b/components/server/src/auth/auth-provider-service.ts index 637baa9f49610e..20073b705958c3 100644 --- a/components/server/src/auth/auth-provider-service.ts +++ b/components/server/src/auth/auth-provider-service.ts @@ -5,7 +5,7 @@ */ import { injectable, inject } from "inversify"; -import { AuthProviderEntry as AuthProviderEntry, User } from "@gitpod/gitpod-protocol"; +import { AuthProviderEntry as AuthProviderEntry, AuthProviderInfo, User } from "@gitpod/gitpod-protocol"; import { AuthProviderParams } from "./auth-provider"; import { AuthProviderEntryDB, TeamDB } from "@gitpod/gitpod-db/lib"; import { Config } from "../config"; @@ -16,22 +16,26 @@ import { oauthUrls as bbsUrls } from "../bitbucket-server/bitbucket-server-urls" import { oauthUrls as bbUrls } from "../bitbucket/bitbucket-urls"; import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; import fetch from "node-fetch"; +import { Authorizer } from "../authorization/authorizer"; +import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; +import { getRequiredScopes, getScopesOfProvider } from "./auth-provider-scopes"; @injectable() export class AuthProviderService { - @inject(AuthProviderEntryDB) - protected authProviderDB: AuthProviderEntryDB; - - @inject(TeamDB) - protected teamDB: TeamDB; - - @inject(Config) - protected readonly config: Config; + constructor( + @inject(AuthProviderEntryDB) private readonly authProviderDB: AuthProviderEntryDB, + @inject(TeamDB) private readonly teamDB: TeamDB, + @inject(Config) protected readonly config: Config, + @inject(Authorizer) private readonly auth: Authorizer, + ) {} /** - * Returns all auth providers. + * Returns all **unredacted** auth provider params to be used in the internal + * authenticator parts. + * + * Known internal client `HostContextProviderImpl` */ - async getAllAuthProviders(exceptOAuthRevisions: string[] = []): Promise { + async getAllAuthProviderParams(exceptOAuthRevisions: string[] = []): Promise { const all = await this.authProviderDB.findAll(exceptOAuthRevisions); return all.map((provider) => this.toAuthProviderParams(provider)); } @@ -40,7 +44,7 @@ export class AuthProviderService { return this.authProviderDB.findAllHosts(); } - protected toAuthProviderParams = (oap: AuthProviderEntry) => + private toAuthProviderParams = (oap: AuthProviderEntry) => { ...oap, // HINT: host is expected to be lower case @@ -56,77 +60,233 @@ export class AuthProviderService { }, }; - async getAuthProvidersOfUser(user: User | string): Promise { - const result = await this.authProviderDB.findByUserId(User.is(user) ? user.id : user); + private isBuiltIn = (info: AuthProviderInfo | AuthProviderParams) => !info.ownerId; + private isNotHidden = (info: AuthProviderInfo | AuthProviderParams) => !info.hiddenOnDashboard; + private isVerified = (info: AuthProviderInfo | AuthProviderParams) => info.verified; + private isNotOrgProvider = (info: AuthProviderInfo | AuthProviderParams) => !info.organizationId; + + async getAuthProviderDescriptionsUnauthenticated(): Promise { + const { builtinAuthProvidersConfigured } = this.config; + + const authProviders = [...(await this.getAllAuthProviderParams()), ...this.config.authProviderConfigs]; + + const toPublic = (ap: AuthProviderParams) => + { + authProviderId: ap.id, + authProviderType: ap.type, + disallowLogin: ap.disallowLogin, + host: ap.host, + icon: ap.icon, + description: ap.description, + }; + let result = authProviders.filter(this.isNotHidden).filter(this.isVerified).filter(this.isNotOrgProvider); + if (builtinAuthProvidersConfigured) { + result = result.filter(this.isBuiltIn); + } + return result.map(toPublic); + } + + async getAuthProviderDescriptions(user: User): Promise { + const { builtinAuthProvidersConfigured } = this.config; + + const authProviders = [...(await this.getAllAuthProviderParams()), ...this.config.authProviderConfigs]; + + // explicitly copy to avoid bleeding sensitive details + const toInfo = (ap: AuthProviderParams) => + { + authProviderId: ap.id, + authProviderType: ap.type, + ownerId: ap.ownerId, + organizationId: ap.organizationId, + verified: ap.verified, + host: ap.host, + icon: ap.icon, + hiddenOnDashboard: ap.hiddenOnDashboard, + disallowLogin: ap.disallowLogin, + description: ap.description, + scopes: getScopesOfProvider(ap), + requirements: getRequiredScopes(ap), + }; + + const result: AuthProviderInfo[] = []; + for (const p of authProviders) { + const identity = user.identities.find((i) => i.authProviderId === p.id); + if (identity) { + result.push(toInfo(p)); + continue; + } + if (p.ownerId === user.id) { + result.push(toInfo(p)); + continue; + } + if (builtinAuthProvidersConfigured && !this.isBuiltIn(p)) { + continue; + } + if (this.isNotHidden(p) && this.isVerified(p)) { + result.push(toInfo(p)); + } + } return result; } - async getAuthProvidersOfOrg(organizationId: string): Promise { + async getAuthProvidersOfUser(user: User | string): Promise { + const userId = User.is(user) ? user.id : user; + await this.auth.checkPermissionOnUser(userId, "read_info", userId); + + const result = await this.authProviderDB.findByUserId(userId); + return result.map((ap) => AuthProviderEntry.redact(ap)); + } + + async getAuthProvidersOfOrg(userId: string, organizationId: string): Promise { + await this.auth.checkPermissionOnOrganization(userId, "read_git_provider", organizationId); const result = await this.authProviderDB.findByOrgId(organizationId); - return result; + return result.map((ap) => AuthProviderEntry.redact(ap)); } - async deleteAuthProvider(authProvider: AuthProviderEntry): Promise { + async deleteAuthProviderOfUser(userId: string, authProviderId: string): Promise { + await this.auth.checkPermissionOnUser(userId, "write_info", userId); + + const ownProviders = await this.getAuthProvidersOfUser(userId); + const authProvider = ownProviders.find((p) => p.id === authProviderId); + if (!authProvider) { + throw new ApplicationError(ErrorCodes.NOT_FOUND, "User resource not found."); + } + await this.authProviderDB.delete(authProvider); } - async updateAuthProvider( - entry: AuthProviderEntry.UpdateEntry | AuthProviderEntry.NewEntry, - ): Promise { - let authProvider: AuthProviderEntry; - if ("id" in entry) { - const { id, ownerId } = entry; - const existing = (await this.authProviderDB.findByUserId(ownerId)).find((p) => p.id === id); - if (!existing) { - throw new Error("Provider does not exist."); - } - const changed = - entry.clientId !== existing.oauth.clientId || - (entry.clientSecret && entry.clientSecret !== existing.oauth.clientSecret); + async deleteAuthProviderOfOrg(userId: string, organizationId: string, authProviderId: string): Promise { + await this.auth.checkPermissionOnOrganization(userId, "write_git_provider", organizationId); - if (!changed) { - return existing; - } + // Find the matching auth provider we're attempting to delete + const orgProviders = await this.getAuthProvidersOfOrg(userId, organizationId); + const authProvider = orgProviders.find((p) => p.id === authProviderId && p.organizationId === organizationId); + if (!authProvider) { + throw new ApplicationError(ErrorCodes.NOT_FOUND, "Provider resource not found."); + } - // update config on demand - const oauth = { - ...existing.oauth, - clientId: entry.clientId, - clientSecret: entry.clientSecret || existing.oauth.clientSecret, // FE may send empty ("") if not changed - }; - authProvider = { - ...existing, - oauth, - status: "pending", - }; + await this.authProviderDB.delete(authProvider); + } + + /** + * Returns the provider identified by the specified `id`. Throws `NOT_FOUND` error if the resource + * is not found. + */ + async getAuthProvider(userId: string, id: string): Promise { + const result = await this.authProviderDB.findById(id); + if (!result) { + throw new ApplicationError(ErrorCodes.NOT_FOUND, "Provider resource not found."); + } + + if (result.organizationId) { + await this.auth.checkPermissionOnOrganization(userId, "read_git_provider", result.organizationId); } else { - const existing = await this.authProviderDB.findByHost(entry.host); - if (existing) { - throw new Error("Provider for this host already exists."); - } - authProvider = this.initializeNewProvider(entry); + await this.auth.checkPermissionOnUser(userId, "read_info", userId); } - return await this.authProviderDB.storeAuthProvider(authProvider as AuthProviderEntry, true); + + return AuthProviderEntry.redact(result); } - async createOrgAuthProvider(entry: AuthProviderEntry.NewOrgEntry): Promise { - const orgProviders = await this.authProviderDB.findByOrgId(entry.organizationId); - const existing = orgProviders.find((p) => p.host === entry.host); + async createAuthProviderOfUser(userId: string, entry: AuthProviderEntry.NewEntry): Promise { + await this.auth.checkPermissionOnUser(userId, "write_info", userId); + + const host = entry.host && entry.host.toLowerCase(); + + // reachability test + if (!(await this.isHostReachable(host))) { + log.info(`Host could not be reached.`, { entry }); + throw new ApplicationError(ErrorCodes.BAD_REQUEST, `Host could not be reached.`); + } + + // checking for already existing runtime providers + const isBuiltInProvider = this.isBuiltInProvider(host); + if (isBuiltInProvider) { + log.info(`Attempt to override an existing provider.`, { entry }); + throw new ApplicationError(ErrorCodes.CONFLICT, `Attempt to override an existing provider.`); + } + const existing = await this.authProviderDB.findByHost(entry.host); if (existing) { - throw new Error("Provider for this host already exists."); + log.info(`Provider for this host already exists.`, { entry }); + throw new ApplicationError(ErrorCodes.CONFLICT, `Provider for this host already exists.`); } const authProvider = this.initializeNewProvider(entry); + const result = await this.authProviderDB.storeAuthProvider(authProvider, true); + return AuthProviderEntry.redact(result); + } - return await this.authProviderDB.storeAuthProvider(authProvider as AuthProviderEntry, true); + private isBuiltInProvider(host: string) { + return this.config.authProviderConfigs.some((config) => config.host.toLowerCase() === host.toLocaleLowerCase()); } - async updateOrgAuthProvider(entry: AuthProviderEntry.UpdateOrgEntry): Promise { + async updateAuthProviderOfUser(userId: string, entry: AuthProviderEntry.UpdateEntry): Promise { + await this.auth.checkPermissionOnUser(userId, "write_info", userId); + + const { id, ownerId } = entry; + const existing = (await this.authProviderDB.findByUserId(ownerId)).find((p) => p.id === id); + if (!existing) { + throw new ApplicationError(ErrorCodes.NOT_FOUND, "Provider resource not found."); + } + const changed = + entry.clientId !== existing.oauth.clientId || + (entry.clientSecret && entry.clientSecret !== existing.oauth.clientSecret); + + if (!changed) { + return existing; + } + + // update config on demand + const oauth = { + ...existing.oauth, + clientId: entry.clientId, + clientSecret: entry.clientSecret || existing.oauth.clientSecret, // FE may send empty ("") if not changed + }; + const authProvider: AuthProviderEntry = { + ...existing, + oauth, + status: "pending", + }; + const result = await this.authProviderDB.storeAuthProvider(authProvider, true); + return AuthProviderEntry.redact(result); + } + + async createOrgAuthProvider(userId: string, newEntry: AuthProviderEntry.NewOrgEntry): Promise { + await this.auth.checkPermissionOnOrganization(userId, "write_git_provider", newEntry.organizationId); + + // on creating we're are checking for already existing runtime providers + const host = newEntry.host && newEntry.host.toLowerCase(); + + if (!(await this.isHostReachable(host))) { + log.info(`Host could not be reached.`, { newEntry }); + throw new ApplicationError(ErrorCodes.BAD_REQUEST, `Host could not be reached.`); + } + + const isBuiltInProvider = this.isBuiltInProvider(host); + if (isBuiltInProvider) { + log.info(`Attempt to override an existing provider.`, { newEntry }); + throw new ApplicationError(ErrorCodes.CONFLICT, `Attempt to override an existing provider.`); + } + + const orgProviders = await this.authProviderDB.findByOrgId(newEntry.organizationId); + const existing = orgProviders.find((p) => p.host === host); + if (existing) { + log.info(`Provider for this host already exists.`, { newEntry }); + throw new ApplicationError(ErrorCodes.CONFLICT, `Provider for this host already exists.`); + } + + const authProvider = this.initializeNewProvider(newEntry); + const result = await this.authProviderDB.storeAuthProvider(authProvider, true); + return AuthProviderEntry.redact(result); + } + + async updateOrgAuthProvider(userId: string, entry: AuthProviderEntry.UpdateOrgEntry): Promise { const { id, organizationId } = entry; + await this.auth.checkPermissionOnOrganization(userId, "write_git_provider", organizationId); + // TODO can we change this to query for the provider by id and org instead of loading all from org? const existing = (await this.authProviderDB.findByOrgId(organizationId)).find((p) => p.id === id); if (!existing) { - throw new Error("Provider does not exist."); + throw new ApplicationError(ErrorCodes.NOT_FOUND, "Provider resource not found."); } const changed = entry.clientId !== existing.oauth.clientId || @@ -148,10 +308,11 @@ export class AuthProviderService { status: "pending", }; - return await this.authProviderDB.storeAuthProvider(authProvider as AuthProviderEntry, true); + const result = await this.authProviderDB.storeAuthProvider(authProvider as AuthProviderEntry, true); + return AuthProviderEntry.redact(result); } - protected initializeNewProvider(newEntry: AuthProviderEntry.NewEntry): AuthProviderEntry { + private initializeNewProvider(newEntry: AuthProviderEntry.NewEntry): AuthProviderEntry { const { host, type, clientId, clientSecret } = newEntry; let urls; switch (type) { @@ -169,7 +330,7 @@ export class AuthProviderService { break; } if (!urls) { - throw new Error("Unexpected service type."); + throw new ApplicationError(ErrorCodes.BAD_REQUEST, "Unexpected service type."); } const oauth: AuthProviderEntry["oauth"] = { ...urls, @@ -237,7 +398,7 @@ export class AuthProviderService { } } - protected callbackUrl = () => { + private callbackUrl = () => { const pathname = `/auth/callback`; return this.config.hostUrl.with({ pathname }).toString(); }; diff --git a/components/server/src/auth/auth-provider.ts b/components/server/src/auth/auth-provider.ts index 180550c960854a..6e3f01666487c4 100644 --- a/components/server/src/auth/auth-provider.ts +++ b/components/server/src/auth/auth-provider.ts @@ -5,31 +5,35 @@ */ import express from "express"; -import { AuthProviderInfo, User, OAuth2Config, AuthProviderEntry } from "@gitpod/gitpod-protocol"; +import { AuthProviderInfo, User, AuthProviderEntry } from "@gitpod/gitpod-protocol"; import { UserEnvVarValue } from "@gitpod/gitpod-protocol"; export const AuthProviderParams = Symbol("AuthProviderParams"); export interface AuthProviderParams extends AuthProviderEntry { - readonly builtin: boolean; // true, if `ownerId` == "" - readonly verified: boolean; // true, if `status` == "verified" + /** + * computed value: `true`, if `ownerId` == "" + */ + readonly builtin: boolean; + /** + * computed value: `true`, if `status` == "verified" + */ + readonly verified: boolean; - readonly oauth: OAuth2Config; - - // for special auth providers only - readonly params?: { - [key: string]: string; - readonly authUrl: string; - readonly callBackUrl: string; - readonly githubToken: string; - }; - - // properties to control behavior readonly hiddenOnDashboard?: boolean; + + /** + * @deprecated unused + */ readonly disallowLogin?: boolean; - readonly requireTOS?: boolean; + /** + * @deprecated unused + */ readonly description: string; + /** + * @deprecated unused + */ readonly icon: string; } export function parseAuthProviderParamsFromEnv(json: object): AuthProviderParams[] { diff --git a/components/server/src/auth/authenticator.ts b/components/server/src/auth/authenticator.ts index b33df1ed30bf0e..e2173ad2f1ceff 100644 --- a/components/server/src/auth/authenticator.ts +++ b/components/server/src/auth/authenticator.ts @@ -16,7 +16,6 @@ import { TokenProvider } from "../user/token-provider"; import { UserAuthentication } from "../user/user-authentication"; import { UserService } from "../user/user-service"; import { AuthFlow, AuthProvider } from "./auth-provider"; -import { AuthProviderService } from "./auth-provider-service"; import { HostContextProvider } from "./host-context-provider"; import { SignInJWT } from "./jwt"; @@ -29,7 +28,6 @@ export class Authenticator { @inject(TeamDB) protected teamDb: TeamDB; @inject(HostContextProvider) protected hostContextProvider: HostContextProvider; @inject(TokenProvider) protected readonly tokenProvider: TokenProvider; - @inject(AuthProviderService) protected readonly authProviderService: AuthProviderService; @inject(UserAuthentication) protected readonly userAuthentication: UserAuthentication; @inject(SignInJWT) protected readonly signInJWT: SignInJWT; diff --git a/components/server/src/auth/generic-auth-provider.ts b/components/server/src/auth/generic-auth-provider.ts index 44ee9783659762..30270b95b89ce2 100644 --- a/components/server/src/auth/generic-auth-provider.ts +++ b/components/server/src/auth/generic-auth-provider.ts @@ -113,7 +113,7 @@ export abstract class GenericAuthProvider implements AuthProvider { disallowLogin, description, scopes, - settingsUrl: this.oauthConfig.settingsUrl, + settingsUrl: this.oauthConfig.settingsUrl, // unused requirements: { default: scopes, publicRepo: scopes, diff --git a/components/server/src/auth/host-context-provider-impl.ts b/components/server/src/auth/host-context-provider-impl.ts index 5ac1ed5707ed6a..e29caa1dfac447 100644 --- a/components/server/src/auth/host-context-provider-impl.ts +++ b/components/server/src/auth/host-context-provider-impl.ts @@ -75,7 +75,7 @@ export class HostContextProviderImpl implements HostContextProvider { const knownOAuthRevisions = Array.from(this.dynamicHosts.entries()) .map(([_, hostContext]) => hostContext.authProvider.params.oauthRevision) .filter((rev) => !!rev) as string[]; - const newAndUpdatedAuthProviders = await this.authProviderService.getAllAuthProviders(knownOAuthRevisions); + const newAndUpdatedAuthProviders = await this.authProviderService.getAllAuthProviderParams(knownOAuthRevisions); ctx.span?.setTag("updateDynamicHosts.newAndUpdatedAuthProviders", newAndUpdatedAuthProviders.length); for (const config of newAndUpdatedAuthProviders) { diff --git a/components/server/src/auth/rate-limiter.ts b/components/server/src/auth/rate-limiter.ts index b87a50dc0f5d42..9fa533bb3217da 100644 --- a/components/server/src/auth/rate-limiter.ts +++ b/components/server/src/auth/rate-limiter.ts @@ -195,6 +195,9 @@ const defaultFunctions: FunctionsConfig = { getIDToken: { group: "default", points: 1 }, reportErrorBoundary: { group: "default", points: 1 }, getOnboardingState: { group: "default", points: 1 }, + getAuthProvider: { group: "default", points: 1 }, + deleteAuthProvider: { group: "default", points: 1 }, + updateAuthProvider: { group: "default", points: 1 }, }; function getConfig(config: RateLimiterConfig): RateLimiterConfig { diff --git a/components/server/src/user/user-deletion-service.ts b/components/server/src/user/user-deletion-service.ts index 4ee7858912882d..56a504be25994a 100644 --- a/components/server/src/user/user-deletion-service.ts +++ b/components/server/src/user/user-deletion-service.ts @@ -45,7 +45,7 @@ export class UserDeletionService { const authProviders = await this.authProviderService.getAuthProvidersOfUser(user); for (const provider of authProviders) { try { - await this.authProviderService.deleteAuthProvider(provider); + await this.authProviderService.deleteAuthProviderOfUser(user.id, provider.id); } catch (error) { log.error({ userId: user.id }, "Failed to delete user's auth provider.", error); } diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 2bffb0cac6361d..b2886a6c5006de 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -626,56 +626,14 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { * If there are built-in auth providers configured, only these are returned. */ public async getAuthProviders(ctx: TraceContext): Promise { - const { builtinAuthProvidersConfigured } = this.config; - - const hostContexts = this.hostContextProvider.getAll(); - const authProviders = hostContexts.map((hc) => hc.authProvider.info); - - const isBuiltIn = (info: AuthProviderInfo) => !info.ownerId; - const isNotHidden = (info: AuthProviderInfo) => !info.hiddenOnDashboard; - const isVerified = (info: AuthProviderInfo) => info.verified; - const isNotOrgProvider = (info: AuthProviderInfo) => !info.organizationId; - - // if no user session is available, compute public information only + // if no user session is available, return public information only if (!this.userID) { - const toPublic = (info: AuthProviderInfo) => - { - authProviderId: info.authProviderId, - authProviderType: info.authProviderType, - disallowLogin: info.disallowLogin, - host: info.host, - icon: info.icon, - description: info.description, - }; - let result = authProviders.filter(isNotHidden).filter(isVerified).filter(isNotOrgProvider); - if (builtinAuthProvidersConfigured) { - result = result.filter(isBuiltIn); - } - return result.map(toPublic); + return await this.authProviderService.getAuthProviderDescriptionsUnauthenticated(); } - const user = await this.checkUser("getAuthProviders"); - // otherwise show all the details - const result: AuthProviderInfo[] = []; - for (const info of authProviders) { - const identity = user.identities.find((i) => i.authProviderId === info.authProviderId); - if (identity) { - result.push({ ...info, isReadonly: identity.readonly }); - continue; - } - if (info.ownerId === user.id) { - result.push(info); - continue; - } - if (builtinAuthProvidersConfigured && !isBuiltIn(info)) { - continue; - } - if (isNotHidden(info) && isVerified(info)) { - result.push(info); - } - } - return result; + const user = await this.checkUser("getAuthProviders"); + return await this.authProviderService.getAuthProviderDescriptions(user); } public async getConfiguration(ctx: TraceContext): Promise { @@ -2941,25 +2899,15 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { const safeProvider = this.redactUpdateOwnAuthProviderParams({ entry }); try { + if ("id" in safeProvider) { + const result = await this.authProviderService.updateAuthProviderOfUser(user.id, safeProvider); + return AuthProviderEntry.redact(result); + } if ("host" in safeProvider) { - // on creating we're are checking for already existing runtime providers - - const host = safeProvider.host && safeProvider.host.toLowerCase(); - - if (!(await this.authProviderService.isHostReachable(host))) { - log.debug(`Host could not be reached.`, { entry, safeProvider }); - throw new Error("Host could not be reached."); - } - - const hostContext = this.hostContextProvider.get(host); - if (hostContext) { - const builtInExists = hostContext.authProvider.params.ownerId === undefined; - log.debug(`Attempt to override existing auth provider.`, { entry, safeProvider, builtInExists }); - throw new Error("Provider for this host already exists."); - } + const result = await this.authProviderService.createAuthProviderOfUser(user.id, safeProvider); + return AuthProviderEntry.redact(result); } - const result = await this.authProviderService.updateAuthProvider(safeProvider); - return AuthProviderEntry.redact(result); + throw new ApplicationError(ErrorCodes.BAD_REQUEST, "Unexpected parameters."); } catch (error) { if (ApplicationError.hasErrorCode(error)) { throw error; @@ -2991,12 +2939,8 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { traceAPIParams(ctx, { params }); const user = await this.checkAndBlockUser("deleteOwnAuthProvider"); - const ownProviders = await this.authProviderService.getAuthProvidersOfUser(user.id); - const authProvider = ownProviders.find((p) => p.id === params.id); - if (!authProvider) { - throw new ApplicationError(ErrorCodes.NOT_FOUND, "User resource not found."); - } - await this.authProviderService.deleteAuthProvider(authProvider); + + await this.authProviderService.deleteAuthProviderOfUser(user.id, params.id); } async createOrgAuthProvider( @@ -3020,12 +2964,6 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { if (!newProvider.organizationId || !uuidValidate(newProvider.organizationId)) { throw new ApplicationError(ErrorCodes.BAD_REQUEST, "Invalid organizationId"); } - - await this.guardWithFeatureFlag("orgGitAuthProviders", user, newProvider.organizationId); - - await this.guardTeamOperation(newProvider.organizationId, "update"); - await this.auth.checkPermissionOnOrganization(user.id, "write_git_provider", newProvider.organizationId); - if (!newProvider.host) { throw new ApplicationError( ErrorCodes.BAD_REQUEST, @@ -3033,23 +2971,12 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { ); } - try { - // on creating we're are checking for already existing runtime providers - const host = newProvider.host && newProvider.host.toLowerCase(); - - if (!(await this.authProviderService.isHostReachable(host))) { - log.debug(`Host could not be reached.`, { entry, newProvider }); - throw new Error("Host could not be reached."); - } + await this.guardWithFeatureFlag("orgGitAuthProviders", user, newProvider.organizationId); - const hostContext = this.hostContextProvider.get(host); - if (hostContext) { - const builtInExists = hostContext.authProvider.params.ownerId === undefined; - log.debug(`Attempt to override existing auth provider.`, { entry, newProvider, builtInExists }); - throw new Error("Provider for this host already exists."); - } + await this.guardTeamOperation(newProvider.organizationId, "update"); - const result = await this.authProviderService.createOrgAuthProvider(newProvider); + try { + const result = await this.authProviderService.createOrgAuthProvider(user.id, newProvider); return AuthProviderEntry.redact(result); } catch (error) { if (ApplicationError.hasErrorCode(error)) { @@ -3083,10 +3010,9 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { await this.guardWithFeatureFlag("orgGitAuthProviders", user, providerUpdate.organizationId); await this.guardTeamOperation(providerUpdate.organizationId, "update"); - await this.auth.checkPermissionOnOrganization(user.id, "write_git_provider", providerUpdate.organizationId); try { - const result = await this.authProviderService.updateOrgAuthProvider(providerUpdate); + const result = await this.authProviderService.updateOrgAuthProvider(user.id, providerUpdate); return AuthProviderEntry.redact(result); } catch (error) { if (ApplicationError.hasErrorCode(error)) { @@ -3108,10 +3034,9 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { await this.guardWithFeatureFlag("orgGitAuthProviders", user, params.organizationId); await this.guardTeamOperation(params.organizationId, "get"); - await this.auth.checkPermissionOnOrganization(user.id, "read_git_provider", params.organizationId); try { - const result = await this.authProviderService.getAuthProvidersOfOrg(params.organizationId); + const result = await this.authProviderService.getAuthProvidersOfOrg(user.id, params.organizationId); return result.map(AuthProviderEntry.redact.bind(AuthProviderEntry)); } catch (error) { if (ApplicationError.hasErrorCode(error)) { @@ -3127,24 +3052,79 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { const user = await this.checkAndBlockUser("deleteOrgAuthProvider"); + // check for "orgGitAuthProviders" feature flag const team = await this.getTeam(ctx, params.organizationId); if (!team) { throw new ApplicationError(ErrorCodes.BAD_REQUEST, "Invalid organizationId"); } - await this.guardWithFeatureFlag("orgGitAuthProviders", user, team.id); await this.guardTeamOperation(params.organizationId || "", "update"); - await this.auth.checkPermissionOnOrganization(user.id, "write_git_provider", params.organizationId); - // Find the matching auth provider we're attempting to delete - const orgProviders = await this.authProviderService.getAuthProvidersOfOrg(team.id); - const authProvider = orgProviders.find((p) => p.id === params.id && p.organizationId === params.organizationId); - if (!authProvider) { - throw new ApplicationError(ErrorCodes.NOT_FOUND, "Provider resource not found."); + await this.authProviderService.deleteAuthProviderOfOrg(user.id, params.organizationId, params.id); + } + + async getAuthProvider(ctx: TraceContextWithSpan, id: string): Promise { + traceAPIParams(ctx, { id }); + + const user = await this.checkAndBlockUser("getAuthProvider"); + + const result = await this.authProviderService.getAuthProvider(user.id, id); + return AuthProviderEntry.redact(result); + } + + /** + * Delegates to `deleteOrgAuthProvider` or `deleteOwnAuthProvider` depending on the ownership + * of the specified auth provider. + */ + async deleteAuthProvider(ctx: TraceContextWithSpan, id: string): Promise { + traceAPIParams(ctx, { id }); + + const user = await this.checkAndBlockUser("deleteAuthProvider"); + + // TODO(at) get rid of the additional read here when user-level providers are migrated to org-level. + const authProvider = await this.authProviderService.getAuthProvider(user.id, id); + if (authProvider.organizationId) { + return this.deleteOrgAuthProvider(ctx, { id, organizationId: authProvider.organizationId }); + } else { + return this.deleteOwnAuthProvider(ctx, { id }); } + } + + /** + * Delegates to `updateOrgAuthProvider` or `updateOwnAuthProvider` depending on the ownership + * of the specified auth provider. + */ + async updateAuthProvider( + ctx: TraceContextWithSpan, + id: string, + update: AuthProviderEntry.UpdateOAuth2Config, + ): Promise { + traceAPIParams(ctx, { id }); - await this.authProviderService.deleteAuthProvider(authProvider); + const user = await this.checkAndBlockUser("updateAuthProvider"); + + const authProvider = await this.authProviderService.getAuthProvider(user.id, id); + + if (authProvider.organizationId) { + return this.updateOrgAuthProvider(ctx, { + entry: { + organizationId: authProvider.organizationId, + id: authProvider.id, + clientId: update.clientId, + clientSecret: update.clientSecret, + }, + }); + } else { + return this.updateOwnAuthProvider(ctx, { + entry: { + id: authProvider.id, + clientId: update.clientId, + clientSecret: update.clientSecret, + ownerId: user.id, + }, + }); + } } async getOnboardingState(ctx: TraceContext): Promise {