Skip to content

[server, protocol] GuardedCostCenter and AttributionId.parse/render (3/4) #11319

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions components/gitpod-protocol/src/attribution.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
}
}
2 changes: 1 addition & 1 deletion components/gitpod-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
43 changes: 42 additions & 1 deletion components/server/ee/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -2058,9 +2059,49 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
}

async getBilledUsage(ctx: TraceContext, attributionId: string): Promise<BillableSession[]> {
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<void> {
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<AccountStatement> {
traceAPIParams(ctx, { userId });
Expand Down
141 changes: 140 additions & 1 deletion components/server/src/auth/resource-access.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down
36 changes: 36 additions & 0 deletions components/server/src/auth/resource-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export type GuardedResource =
| GuardedContentBlob
| GuardEnvVar
| GuardedTeam
| GuardedCostCenter
| GuardedWorkspaceLog
| GuardedPrebuild;

Expand All @@ -51,6 +52,7 @@ const ALL_GUARDED_RESOURCE_KINDS = new Set<GuardedResourceKind>([
"contentBlob",
"envVar",
"team",
"costCenter",
"workspaceLog",
]);
export function isGuardedResourceKind(kind: any): kind is GuardedResourceKind {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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":
Expand Down
9 changes: 5 additions & 4 deletions components/server/src/user/user-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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 });
}

/**
Expand Down