Skip to content

Commit c9e8162

Browse files
committed
[server, protocol] GuardedCostCenter and AttributionId.parse/render
1 parent dfde0b6 commit c9e8162

File tree

5 files changed

+269
-10
lines changed

5 files changed

+269
-10
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* Copyright (c) 2022 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+
export type AttributionId = UserAttributionId | TeamAttributionId;
8+
export type AttributionTarget = "user" | "team";
9+
10+
export interface UserAttributionId {
11+
kind: "user";
12+
userId: string;
13+
}
14+
export interface TeamAttributionId {
15+
kind: "team";
16+
teamId: string;
17+
}
18+
19+
export namespace AttributionId {
20+
const SEPARATOR = ":";
21+
22+
export function parse(s: string): UserAttributionId | TeamAttributionId | undefined {
23+
if (!s) {
24+
return undefined;
25+
}
26+
const parts = s.split(":");
27+
if (parts.length !== 2) {
28+
return undefined;
29+
}
30+
switch (parts[0]) {
31+
case "user":
32+
return { kind: "user", userId: parts[1] };
33+
case "team":
34+
return { kind: "team", teamId: parts[1] };
35+
default:
36+
return undefined;
37+
}
38+
}
39+
40+
export function render(id: AttributionId): string {
41+
switch (id.kind) {
42+
case "user":
43+
return `user${SEPARATOR}${id.userId}`;
44+
case "team":
45+
return `team${SEPARATOR}${id.teamId}`;
46+
}
47+
}
48+
}

components/server/ee/src/workspace/gitpod-server-impl.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ import { Feature } from "@gitpod/licensor/lib/api";
6565
import { LicenseValidationResult, LicenseFeature } from "@gitpod/gitpod-protocol/lib/license-protocol";
6666
import { PrebuildManager } from "../prebuilds/prebuild-manager";
6767
import { LicenseDB } from "@gitpod/gitpod-db/lib";
68-
import { ResourceAccessGuard } from "../../../src/auth/resource-access";
68+
import { GuardedCostCenter, ResourceAccessGuard, ResourceAccessOp } from "../../../src/auth/resource-access";
6969
import { AccountStatement, CreditAlert, Subscription } from "@gitpod/gitpod-protocol/lib/accounting-protocol";
7070
import { BlockedRepository } from "@gitpod/gitpod-protocol/lib/blocked-repositories-protocol";
7171
import { EligibilityService } from "../user/eligibility-service";
@@ -108,6 +108,7 @@ import { UserCounter } from "../user/user-counter";
108108
import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server";
109109
import { CachingUsageServiceClientProvider } from "@gitpod/usage-api/lib/usage/v1/sugar";
110110
import * as usage from "@gitpod/usage-api/lib/usage/v1/usage_pb";
111+
import { AttributionId } from "@gitpod/gitpod-protocol/src/attribution";
111112

112113
@injectable()
113114
export class GitpodServerEEImpl extends GitpodServerImpl {
@@ -2065,11 +2066,9 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
20652066

20662067
async getBilledUsage(ctx: TraceContext, attributionId: string): Promise<BillableSession[]> {
20672068
traceAPIParams(ctx, { attributionId });
2068-
this.checkAndBlockUser("getBilledUsage");
2069+
const user = this.checkAndBlockUser("getBilledUsage");
20692070

2070-
// TODO(gpl) We need a GuardedCostCenter to authorize access to reports
2071-
// const costCenter = await this.costCenterDB.findByAttributionId(attributionId);
2072-
// await this.guardAccess({ kind: "GuardedCostCenter", subject: costCenter }, "get");
2071+
await this.guardCostCenterAccess(ctx, user.id, attributionId, "get");
20732072

20742073
const usageClient = this.usageServiceClientProvider.getDefault();
20752074
const response = await usageClient.getBilledUsage(ctx, attributionId);
@@ -2078,6 +2077,42 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
20782077
return sessions;
20792078
}
20802079

2080+
protected async guardCostCenterAccess(
2081+
ctx: TraceContext,
2082+
userId: string,
2083+
attributionId: string,
2084+
operation: ResourceAccessOp,
2085+
): Promise<void> {
2086+
traceAPIParams(ctx, { userId, attributionId });
2087+
2088+
// TODO(gpl) We need a CostCenter entity (with a strong connection to Team or User) to properly to authorize access to these reports
2089+
// const costCenter = await this.costCenterDB.findByAttributionId(attributionId);
2090+
const parsedId = AttributionId.parse(attributionId);
2091+
if (parsedId === undefined) {
2092+
log.warn({ userId }, "Unable to parse attributionId", { attributionId });
2093+
throw new ResponseError(ErrorCodes.BAD_REQUEST, "Unable to parse attributionId");
2094+
}
2095+
2096+
let owner: GuardedCostCenter["owner"];
2097+
switch (parsedId.kind) {
2098+
case "team":
2099+
const team = await this.teamDB.findTeamById(parsedId.teamId);
2100+
if (!team) {
2101+
throw new ResponseError(ErrorCodes.NOT_FOUND, "Team not found");
2102+
}
2103+
const members = await this.teamDB.findMembersByTeam(team.id);
2104+
owner = { kind: "team", team, members };
2105+
break;
2106+
case "user":
2107+
owner = { kind: "user", userId };
2108+
break;
2109+
default:
2110+
throw new ResponseError(ErrorCodes.BAD_REQUEST, "Invalid attributionId");
2111+
}
2112+
2113+
await this.guardAccess({ kind: "costCenter", /*subject: costCenter,*/ owner }, operation);
2114+
}
2115+
20812116
protected mapBilledSession(s: usage.BilledSession): BillableSession {
20822117
function mandatory<T>(s: T, m: (s: T) => string = (s) => "" + s): string {
20832118
if (!s) {

components/server/src/auth/resource-access.spec.ts

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@ import {
2121
GuardedResourceKind,
2222
RepositoryResourceGuard,
2323
SharedWorkspaceAccessGuard,
24+
GuardedCostCenter,
2425
} from "./resource-access";
2526
import { PrebuiltWorkspace, User, UserEnvVar, Workspace, WorkspaceType } from "@gitpod/gitpod-protocol/lib/protocol";
26-
import { TeamMemberInfo, TeamMemberRole, WorkspaceInstance } from "@gitpod/gitpod-protocol";
27+
import { Team, TeamMemberInfo, TeamMemberRole, WorkspaceInstance } from "@gitpod/gitpod-protocol";
2728
import { HostContextProvider } from "./host-context-provider";
2829

2930
class MockedRepositoryResourceGuard extends RepositoryResourceGuard {
@@ -233,6 +234,144 @@ class TestResourceAccess {
233234
}
234235
}
235236

237+
@test public async costCenterResourceGuard() {
238+
const createUser = (): User => {
239+
return {
240+
id: "123",
241+
name: "testuser",
242+
creationDate: new Date(2000, 1, 1).toISOString(),
243+
identities: [
244+
{
245+
authId: "123",
246+
authName: "testuser",
247+
authProviderId: "github.com",
248+
},
249+
],
250+
};
251+
};
252+
253+
const tests: {
254+
name: string;
255+
isOwner?: boolean;
256+
teamRole?: TeamMemberRole;
257+
operation: ResourceAccessOp;
258+
expectation: boolean;
259+
}[] = [
260+
// member
261+
{
262+
name: "member - get",
263+
teamRole: "member",
264+
operation: "get",
265+
expectation: false,
266+
},
267+
{
268+
name: "member - update",
269+
teamRole: "member",
270+
operation: "update",
271+
expectation: false,
272+
},
273+
{
274+
name: "member - update",
275+
teamRole: "member",
276+
operation: "create",
277+
expectation: false,
278+
},
279+
{
280+
name: "member - delete",
281+
teamRole: "member",
282+
operation: "delete",
283+
expectation: false,
284+
},
285+
// team owner
286+
{
287+
name: "team owner - get",
288+
teamRole: "owner",
289+
operation: "get",
290+
expectation: true,
291+
},
292+
{
293+
name: "team owner - update",
294+
teamRole: "owner",
295+
operation: "update",
296+
expectation: true,
297+
},
298+
{
299+
name: "team owner - update",
300+
teamRole: "owner",
301+
operation: "create",
302+
expectation: true,
303+
},
304+
{
305+
name: "team owner - delete",
306+
teamRole: "owner",
307+
operation: "delete",
308+
expectation: true,
309+
},
310+
// owner
311+
{
312+
name: "owner - get",
313+
isOwner: true,
314+
operation: "get",
315+
expectation: true,
316+
},
317+
{
318+
name: "owner - update",
319+
isOwner: true,
320+
operation: "update",
321+
expectation: true,
322+
},
323+
{
324+
name: "owner - update",
325+
isOwner: true,
326+
operation: "create",
327+
expectation: true,
328+
},
329+
{
330+
name: "owner - delete",
331+
isOwner: true,
332+
operation: "delete",
333+
expectation: true,
334+
},
335+
];
336+
337+
for (const t of tests) {
338+
const user = createUser();
339+
const team: Team = {
340+
id: "team-123",
341+
name: "test-team",
342+
creationTime: user.creationDate,
343+
slug: "test-team",
344+
};
345+
const resourceGuard = new CompositeResourceAccessGuard([
346+
new OwnerResourceGuard(user.id),
347+
new TeamMemberResourceGuard(user.id),
348+
new SharedWorkspaceAccessGuard(),
349+
new MockedRepositoryResourceGuard(true),
350+
]);
351+
352+
let owner: GuardedCostCenter["owner"];
353+
if (t.isOwner) {
354+
owner = { kind: "user", userId: user.id };
355+
} else if (!!t.teamRole) {
356+
const teamMembers: TeamMemberInfo[] = [
357+
{
358+
userId: user.id,
359+
role: t.teamRole,
360+
memberSince: user.creationDate,
361+
},
362+
];
363+
owner = { kind: "team", team, members: teamMembers };
364+
} else {
365+
fail("Bad test data: expected at least isWoner or teamRole to be configured!");
366+
}
367+
const actual = await resourceGuard.canAccess({ kind: "costCenter", owner }, "get");
368+
expect(actual).to.be.eq(
369+
t.expectation,
370+
`"${t.name}" expected canAccess(resource, "${t.operation}") === ${t.expectation}, but was ${actual}`,
371+
);
372+
}
373+
}
374+
236375
@test public async tokenResourceGuardCanAccess() {
237376
const workspaceResource: GuardedResource = {
238377
kind: "workspace",

components/server/src/auth/resource-access.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export type GuardedResource =
3737
| GuardedContentBlob
3838
| GuardEnvVar
3939
| GuardedTeam
40+
| GuardedCostCenter
4041
| GuardedWorkspaceLog
4142
| GuardedPrebuild;
4243

@@ -51,6 +52,7 @@ const ALL_GUARDED_RESOURCE_KINDS = new Set<GuardedResourceKind>([
5152
"contentBlob",
5253
"envVar",
5354
"team",
55+
"costCenter",
5456
"workspaceLog",
5557
]);
5658
export function isGuardedResourceKind(kind: any): kind is GuardedResourceKind {
@@ -104,6 +106,24 @@ export interface GuardedTeam {
104106
members: TeamMemberInfo[];
105107
}
106108

109+
export interface GuardedCostCenter {
110+
kind: "costCenter";
111+
//subject: CostCenter;
112+
owner: CostCenterOwner;
113+
// team: Team;
114+
// members: TeamMemberInfo[];
115+
}
116+
type CostCenterOwner =
117+
| {
118+
kind: "user";
119+
userId: string;
120+
}
121+
| {
122+
kind: "team";
123+
team: Team;
124+
members: TeamMemberInfo[];
125+
};
126+
107127
export interface GuardedGitpodToken {
108128
kind: "gitpodToken";
109129
subject: GitpodToken;
@@ -165,6 +185,15 @@ export class TeamMemberResourceGuard implements ResourceAccessGuard {
165185
return await this.hasAccessToWorkspace(resource.subject, resource.teamMembers);
166186
case "prebuild":
167187
return !!resource.teamMembers?.some((m) => m.userId === this.userId);
188+
case "costCenter":
189+
const owner = resource.owner;
190+
if (owner.kind === "user") {
191+
// This is handled in the "OwnerResourceGuard"
192+
return false;
193+
}
194+
// TODO(gpl) We should check whether we're looking at the right team for the right CostCenter here!
195+
// Only team "owners" are allowed to do anything with CostCenters
196+
return owner.members.filter((m) => m.role === "owner").some((m) => m.userId === this.userId);
168197
}
169198
return false;
170199
}
@@ -218,6 +247,13 @@ export class OwnerResourceGuard implements ResourceAccessGuard {
218247
// Only owners can update or delete a team.
219248
return resource.members.some((m) => m.userId === this.userId && m.role === "owner");
220249
}
250+
case "costCenter":
251+
const owner = resource.owner;
252+
if (owner.kind === "team") {
253+
// This is handled in the "TeamMemberResourceGuard"
254+
return false;
255+
}
256+
return owner.userId === this.userId;
221257
case "workspaceLog":
222258
return resource.subject.ownerId === this.userId;
223259
case "prebuild":

components/server/src/user/user-service.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { TermsProvider } from "../terms/terms-provider";
2727
import { TokenService } from "./token-service";
2828
import { EmailAddressAlreadyTakenException, SelectAccountException } from "../auth/errors";
2929
import { SelectAccountPayload } from "@gitpod/gitpod-protocol/lib/auth";
30+
import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution";
3031

3132
export interface FindUserByIdentityStrResult {
3233
user: User;
@@ -226,7 +227,7 @@ export class UserService {
226227
if (this.config.enablePayment) {
227228
if (!user.additionalData?.usageAttributionId) {
228229
// No explicit user attribution ID yet -- attribute all usage to the user by default (regardless of project/team).
229-
return `user:${user.id}`;
230+
return AttributionId.render({ kind: "user", userId: user.id });
230231
}
231232
// Return the user's explicit attribution ID.
232233
return user.additionalData.usageAttributionId;
@@ -235,15 +236,15 @@ export class UserService {
235236
// B. Project-based attribution
236237
if (!projectId) {
237238
// No project -- attribute to the user.
238-
return `user:${user.id}`;
239+
return AttributionId.render({ kind: "user", userId: user.id });
239240
}
240241
const project = await this.projectDb.findProjectById(projectId);
241242
if (!project?.teamId) {
242243
// The project doesn't exist, or it isn't owned by a team -- attribute to the user.
243-
return `user:${user.id}`;
244+
return AttributionId.render({ kind: "user", userId: user.id });
244245
}
245246
// Attribute workspace usage to the team that currently owns this project.
246-
return `team:${project.teamId}`;
247+
return AttributionId.render({ kind: "team", teamId: project.teamId });
247248
}
248249

249250
/**

0 commit comments

Comments
 (0)