From 96d1efb684121cb17da8224df5c593e910baa466 Mon Sep 17 00:00:00 2001 From: Gero Posmyk-Leinemann Date: Tue, 12 Jul 2022 13:30:01 +0000 Subject: [PATCH] [server, protocol] GuardedCostCenter and AttributionId.parse/render --- components/gitpod-protocol/src/attribution.ts | 48 ++++++ components/gitpod-protocol/src/protocol.ts | 2 +- .../ee/src/workspace/gitpod-server-impl.ts | 43 +++++- .../server/src/auth/resource-access.spec.ts | 141 +++++++++++++++++- components/server/src/auth/resource-access.ts | 36 +++++ components/server/src/user/user-service.ts | 9 +- 6 files changed, 272 insertions(+), 7 deletions(-) create mode 100644 components/gitpod-protocol/src/attribution.ts diff --git a/components/gitpod-protocol/src/attribution.ts b/components/gitpod-protocol/src/attribution.ts new file mode 100644 index 00000000000000..f9d872f7be9baf --- /dev/null +++ b/components/gitpod-protocol/src/attribution.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2022 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. + */ + +export type AttributionId = UserAttributionId | TeamAttributionId; +export type AttributionTarget = "user" | "team"; + +export interface UserAttributionId { + kind: "user"; + userId: string; +} +export interface TeamAttributionId { + kind: "team"; + teamId: string; +} + +export namespace AttributionId { + const SEPARATOR = ":"; + + export function parse(s: string): UserAttributionId | TeamAttributionId | undefined { + if (!s) { + return undefined; + } + const parts = s.split(":"); + if (parts.length !== 2) { + return undefined; + } + switch (parts[0]) { + case "user": + return { kind: "user", userId: parts[1] }; + case "team": + return { kind: "team", teamId: parts[1] }; + default: + return undefined; + } + } + + export function render(id: AttributionId): string { + switch (id.kind) { + case "user": + return `user${SEPARATOR}${id.userId}`; + case "team": + return `team${SEPARATOR}${id.teamId}`; + } + } +} diff --git a/components/gitpod-protocol/src/protocol.ts b/components/gitpod-protocol/src/protocol.ts index 021cfb69ca80a4..2da4d97b5c1127 100644 --- a/components/gitpod-protocol/src/protocol.ts +++ b/components/gitpod-protocol/src/protocol.ts @@ -694,7 +694,7 @@ export interface Workspace { export type WorkspaceSoftDeletion = "user" | "gc"; -export type WorkspaceType = "regular" | "prebuild" | "probe"; +export type WorkspaceType = "regular" | "prebuild" | "probe"; // TODO(gpl) Removed prove here export namespace Workspace { export function getFullRepositoryName(ws: Workspace): string | undefined { diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index c3e17398d38000..b03f877676d6b4 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -64,7 +64,7 @@ import { Feature } from "@gitpod/licensor/lib/api"; import { LicenseValidationResult, LicenseFeature } from "@gitpod/gitpod-protocol/lib/license-protocol"; import { PrebuildManager } from "../prebuilds/prebuild-manager"; import { LicenseDB } from "@gitpod/gitpod-db/lib"; -import { ResourceAccessGuard } from "../../../src/auth/resource-access"; +import { GuardedCostCenter, ResourceAccessGuard, ResourceAccessOp } from "../../../src/auth/resource-access"; import { AccountStatement, CreditAlert, Subscription } from "@gitpod/gitpod-protocol/lib/accounting-protocol"; import { BlockedRepository } from "@gitpod/gitpod-protocol/lib/blocked-repositories-protocol"; import { EligibilityService } from "../user/eligibility-service"; @@ -105,6 +105,7 @@ import { BitbucketAppSupport } from "../bitbucket/bitbucket-app-support"; import { URL } from "url"; import { UserCounter } from "../user/user-counter"; import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server"; +import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; @injectable() export class GitpodServerEEImpl extends GitpodServerImpl { @@ -2058,9 +2059,49 @@ export class GitpodServerEEImpl extends GitpodServerImpl { } async getBilledUsage(ctx: TraceContext, attributionId: string): Promise { + traceAPIParams(ctx, { attributionId }); + const user = this.checkAndBlockUser("getBilledUsage"); + + await this.guardCostCenterAccess(ctx, user.id, attributionId, "get"); + return billableSessionDummyData; } + protected async guardCostCenterAccess( + ctx: TraceContext, + userId: string, + attributionId: string, + operation: ResourceAccessOp, + ): Promise { + traceAPIParams(ctx, { userId, attributionId }); + + // TODO(gpl) We need a CostCenter entity (with a strong connection to Team or User) to properly to authorize access to these reports + // const costCenter = await this.costCenterDB.findByAttributionId(attributionId); + const parsedId = AttributionId.parse(attributionId); + if (parsedId === undefined) { + log.warn({ userId }, "Unable to parse attributionId", { attributionId }); + throw new ResponseError(ErrorCodes.BAD_REQUEST, "Unable to parse attributionId"); + } + + let owner: GuardedCostCenter["owner"]; + switch (parsedId.kind) { + case "team": + const team = await this.teamDB.findTeamById(parsedId.teamId); + if (!team) { + throw new ResponseError(ErrorCodes.NOT_FOUND, "Team not found"); + } + const members = await this.teamDB.findMembersByTeam(team.id); + owner = { kind: "team", team, members }; + break; + case "user": + owner = { kind: "user", userId }; + break; + default: + throw new ResponseError(ErrorCodes.BAD_REQUEST, "Invalid attributionId"); + } + + await this.guardAccess({ kind: "costCenter", /*subject: costCenter,*/ owner }, operation); + } // (SaaS) – admin async adminGetAccountStatement(ctx: TraceContext, userId: string): Promise { traceAPIParams(ctx, { userId }); diff --git a/components/server/src/auth/resource-access.spec.ts b/components/server/src/auth/resource-access.spec.ts index aa35915a001ae5..9df145744198ff 100644 --- a/components/server/src/auth/resource-access.spec.ts +++ b/components/server/src/auth/resource-access.spec.ts @@ -21,9 +21,10 @@ import { GuardedResourceKind, RepositoryResourceGuard, SharedWorkspaceAccessGuard, + GuardedCostCenter, } from "./resource-access"; import { PrebuiltWorkspace, User, UserEnvVar, Workspace, WorkspaceType } from "@gitpod/gitpod-protocol/lib/protocol"; -import { TeamMemberInfo, TeamMemberRole, WorkspaceInstance } from "@gitpod/gitpod-protocol"; +import { Team, TeamMemberInfo, TeamMemberRole, WorkspaceInstance } from "@gitpod/gitpod-protocol"; import { HostContextProvider } from "./host-context-provider"; class MockedRepositoryResourceGuard extends RepositoryResourceGuard { @@ -233,6 +234,144 @@ class TestResourceAccess { } } + @test public async costCenterResourceGuard() { + const createUser = (): User => { + return { + id: "123", + name: "testuser", + creationDate: new Date(2000, 1, 1).toISOString(), + identities: [ + { + authId: "123", + authName: "testuser", + authProviderId: "github.com", + }, + ], + }; + }; + + const tests: { + name: string; + isOwner?: boolean; + teamRole?: TeamMemberRole; + operation: ResourceAccessOp; + expectation: boolean; + }[] = [ + // member + { + name: "member - get", + teamRole: "member", + operation: "get", + expectation: false, + }, + { + name: "member - update", + teamRole: "member", + operation: "update", + expectation: false, + }, + { + name: "member - update", + teamRole: "member", + operation: "create", + expectation: false, + }, + { + name: "member - delete", + teamRole: "member", + operation: "delete", + expectation: false, + }, + // team owner + { + name: "team owner - get", + teamRole: "owner", + operation: "get", + expectation: true, + }, + { + name: "team owner - update", + teamRole: "owner", + operation: "update", + expectation: true, + }, + { + name: "team owner - update", + teamRole: "owner", + operation: "create", + expectation: true, + }, + { + name: "team owner - delete", + teamRole: "owner", + operation: "delete", + expectation: true, + }, + // owner + { + name: "owner - get", + isOwner: true, + operation: "get", + expectation: true, + }, + { + name: "owner - update", + isOwner: true, + operation: "update", + expectation: true, + }, + { + name: "owner - update", + isOwner: true, + operation: "create", + expectation: true, + }, + { + name: "owner - delete", + isOwner: true, + operation: "delete", + expectation: true, + }, + ]; + + for (const t of tests) { + const user = createUser(); + const team: Team = { + id: "team-123", + name: "test-team", + creationTime: user.creationDate, + slug: "test-team", + }; + const resourceGuard = new CompositeResourceAccessGuard([ + new OwnerResourceGuard(user.id), + new TeamMemberResourceGuard(user.id), + new SharedWorkspaceAccessGuard(), + new MockedRepositoryResourceGuard(true), + ]); + + let owner: GuardedCostCenter["owner"]; + if (t.isOwner) { + owner = { kind: "user", userId: user.id }; + } else if (!!t.teamRole) { + const teamMembers: TeamMemberInfo[] = [ + { + userId: user.id, + role: t.teamRole, + memberSince: user.creationDate, + }, + ]; + owner = { kind: "team", team, members: teamMembers }; + } else { + fail("Bad test data: expected at least isWoner or teamRole to be configured!"); + } + const actual = await resourceGuard.canAccess({ kind: "costCenter", owner }, "get"); + expect(actual).to.be.eq( + t.expectation, + `"${t.name}" expected canAccess(resource, "${t.operation}") === ${t.expectation}, but was ${actual}`, + ); + } + } + @test public async tokenResourceGuardCanAccess() { const workspaceResource: GuardedResource = { kind: "workspace", diff --git a/components/server/src/auth/resource-access.ts b/components/server/src/auth/resource-access.ts index 4c303c6ef1cd1b..cac4c8ac43c156 100644 --- a/components/server/src/auth/resource-access.ts +++ b/components/server/src/auth/resource-access.ts @@ -37,6 +37,7 @@ export type GuardedResource = | GuardedContentBlob | GuardEnvVar | GuardedTeam + | GuardedCostCenter | GuardedWorkspaceLog | GuardedPrebuild; @@ -51,6 +52,7 @@ const ALL_GUARDED_RESOURCE_KINDS = new Set([ "contentBlob", "envVar", "team", + "costCenter", "workspaceLog", ]); export function isGuardedResourceKind(kind: any): kind is GuardedResourceKind { @@ -104,6 +106,24 @@ export interface GuardedTeam { members: TeamMemberInfo[]; } +export interface GuardedCostCenter { + kind: "costCenter"; + //subject: CostCenter; + owner: CostCenterOwner; + // team: Team; + // members: TeamMemberInfo[]; +} +type CostCenterOwner = + | { + kind: "user"; + userId: string; + } + | { + kind: "team"; + team: Team; + members: TeamMemberInfo[]; + }; + export interface GuardedGitpodToken { kind: "gitpodToken"; subject: GitpodToken; @@ -165,6 +185,15 @@ export class TeamMemberResourceGuard implements ResourceAccessGuard { return await this.hasAccessToWorkspace(resource.subject, resource.teamMembers); case "prebuild": return !!resource.teamMembers?.some((m) => m.userId === this.userId); + case "costCenter": + const owner = resource.owner; + if (owner.kind === "user") { + // This is handled in the "OwnerResourceGuard" + return false; + } + // TODO(gpl) We should check whether we're looking at the right team for the right CostCenter here! + // Only team "owners" are allowed to do anything with CostCenters + return owner.members.filter((m) => m.role === "owner").some((m) => m.userId === this.userId); } return false; } @@ -218,6 +247,13 @@ export class OwnerResourceGuard implements ResourceAccessGuard { // Only owners can update or delete a team. return resource.members.some((m) => m.userId === this.userId && m.role === "owner"); } + case "costCenter": + const owner = resource.owner; + if (owner.kind === "team") { + // This is handled in the "TeamMemberResourceGuard" + return false; + } + return owner.userId === this.userId; case "workspaceLog": return resource.subject.ownerId === this.userId; case "prebuild": diff --git a/components/server/src/user/user-service.ts b/components/server/src/user/user-service.ts index 7c15762c1270c4..9dfe7b79e28b03 100644 --- a/components/server/src/user/user-service.ts +++ b/components/server/src/user/user-service.ts @@ -27,6 +27,7 @@ import { TermsProvider } from "../terms/terms-provider"; import { TokenService } from "./token-service"; import { EmailAddressAlreadyTakenException, SelectAccountException } from "../auth/errors"; import { SelectAccountPayload } from "@gitpod/gitpod-protocol/lib/auth"; +import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; export interface FindUserByIdentityStrResult { user: User; @@ -226,7 +227,7 @@ export class UserService { if (this.config.enablePayment) { if (!user.additionalData?.usageAttributionId) { // No explicit user attribution ID yet -- attribute all usage to the user by default (regardless of project/team). - return `user:${user.id}`; + return AttributionId.render({ kind: "user", userId: user.id }); } // Return the user's explicit attribution ID. return user.additionalData.usageAttributionId; @@ -235,15 +236,15 @@ export class UserService { // B. Project-based attribution if (!projectId) { // No project -- attribute to the user. - return `user:${user.id}`; + return AttributionId.render({ kind: "user", userId: user.id }); } const project = await this.projectDb.findProjectById(projectId); if (!project?.teamId) { // The project doesn't exist, or it isn't owned by a team -- attribute to the user. - return `user:${user.id}`; + return AttributionId.render({ kind: "user", userId: user.id }); } // Attribute workspace usage to the team that currently owns this project. - return `team:${project.teamId}`; + return AttributionId.render({ kind: "team", teamId: project.teamId }); } /**