Skip to content

Commit c0fe7d6

Browse files
committed
[server] Perform authorization checks for Orgs against spicedb
1 parent 5ad8f32 commit c0fe7d6

File tree

6 files changed

+270
-35
lines changed

6 files changed

+270
-35
lines changed

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1596,7 +1596,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
15961596
// Team Subscriptions 2
15971597
async getTeamSubscription(ctx: TraceContext, teamId: string): Promise<TeamSubscription2 | undefined> {
15981598
this.checkUser("getTeamSubscription");
1599-
await this.guardTeamOperation(teamId, "get", ["not_implemented"]);
1599+
await this.guardTeamOperation(teamId, "get", "not_implemented");
16001600
return this.teamSubscription2DB.findForTeam(teamId, new Date().toISOString());
16011601
}
16021602

@@ -2098,7 +2098,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
20982098

20992099
try {
21002100
if (attrId.kind == "team") {
2101-
await this.guardTeamOperation(attrId.teamId, "get", ["not_implemented"]);
2101+
await this.guardTeamOperation(attrId.teamId, "get", "not_implemented");
21022102
}
21032103
const subscriptionId = await this.stripeService.findUncancelledSubscriptionByAttributionId(attributionId);
21042104
return subscriptionId;
@@ -2119,7 +2119,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
21192119
}
21202120
let team: Team | undefined;
21212121
if (attrId.kind === "team") {
2122-
team = (await this.guardTeamOperation(attrId.teamId, "update", ["not_implemented"])).team;
2122+
team = (await this.guardTeamOperation(attrId.teamId, "update", "not_implemented")).team;
21232123
await this.ensureStripeApiIsAllowed({ team });
21242124
} else {
21252125
if (attrId.userId !== user.id) {
@@ -2141,7 +2141,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
21412141
}
21422142
let team: Team | undefined;
21432143
if (attrId.kind === "team") {
2144-
team = (await this.guardTeamOperation(attrId.teamId, "update", ["not_implemented"])).team;
2144+
team = (await this.guardTeamOperation(attrId.teamId, "update", "not_implemented")).team;
21452145
await this.ensureStripeApiIsAllowed({ team });
21462146
} else {
21472147
if (attrId.userId !== user.id) {
@@ -2211,7 +2211,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
22112211
let team: Team | undefined;
22122212
try {
22132213
if (attrId.kind === "team") {
2214-
team = (await this.guardTeamOperation(attrId.teamId, "update", ["not_implemented"])).team;
2214+
team = (await this.guardTeamOperation(attrId.teamId, "update", "not_implemented")).team;
22152215
await this.ensureStripeApiIsAllowed({ team });
22162216
} else {
22172217
await this.ensureStripeApiIsAllowed({ user });
@@ -2257,7 +2257,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
22572257
if (attrId.kind === "user") {
22582258
await this.ensureStripeApiIsAllowed({ user });
22592259
} else if (attrId.kind === "team") {
2260-
const team = (await this.guardTeamOperation(attrId.teamId, "update", ["not_implemented"])).team;
2260+
const team = (await this.guardTeamOperation(attrId.teamId, "update", "not_implemented")).team;
22612261
await this.ensureStripeApiIsAllowed({ team });
22622262
returnUrl = this.config.hostUrl
22632263
.with(() => ({ pathname: `/org-billing`, search: `org=${team.id}` }))
@@ -2493,7 +2493,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
24932493
traceAPIParams(ctx, { teamId });
24942494

24952495
this.checkAndBlockUser("getBillingModeForTeam");
2496-
const { team } = await this.guardTeamOperation(teamId, "get", ["not_implemented"]);
2496+
const { team } = await this.guardTeamOperation(teamId, "get", "not_implemented");
24972497

24982498
return this.billingModes.getBillingModeForTeam(team, new Date());
24992499
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { v1 } from "@authzed/authzed-node";
8+
import { ResourceType, SubjectType } from "./perms";
9+
10+
const FULLY_CONSISTENT = v1.Consistency.create({
11+
requirement: {
12+
oneofKind: "fullyConsistent",
13+
fullyConsistent: true,
14+
},
15+
});
16+
17+
type SubjectResourceCheckFn = (subjectID: string, resourceID: string) => v1.CheckPermissionRequest;
18+
19+
function check(subjectT: SubjectType, op: string, resourceT: ResourceType): SubjectResourceCheckFn {
20+
return (subjectID, resourceID) =>
21+
v1.CheckPermissionRequest.create({
22+
subject: v1.SubjectReference.create({
23+
object: v1.ObjectReference.create({
24+
objectId: subjectID,
25+
objectType: subjectT,
26+
}),
27+
}),
28+
permission: op,
29+
resource: v1.ObjectReference.create({
30+
objectId: resourceID,
31+
objectType: resourceT,
32+
}),
33+
consistency: FULLY_CONSISTENT,
34+
});
35+
}
36+
37+
export const ReadOrganizationMetadata = check("user", "organization_metadata_read", "organization");
38+
export const WriteOrganizationMetadata = check("user", "organization_metadata_write", "organization");
39+
40+
export const ReadOrganizationMembers = check("user", "organization_members_read", "organization");
41+
export const WriteOrganizationMembers = check("user", "organization_members_write", "organization");

components/server/src/authorization/perms.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@
44
* See License.AGPL.txt in the project root for license information.
55
*/
66

7+
import { v1 } from "@authzed/authzed-node";
8+
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
9+
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
10+
import { inject, injectable } from "inversify";
11+
import { ResponseError } from "vscode-ws-jsonrpc";
12+
import {
13+
observespicedbClientLatency as observeSpicedbClientLatency,
14+
spicedbClientLatency,
15+
} from "../prometheus-metrics";
16+
import { SpiceDBClient } from "./spicedb";
17+
718
export type OrganizationOperation =
819
// A not yet implemented operation at this time. This exists such that we can be explicit about what
920
// we have not yet migrated to fine-grained-permissions.
@@ -31,3 +42,66 @@ export type OrganizationOperation =
3142
| "org_authprovider_write"
3243
// Ability to read Organization Auth Providers.
3344
| "org_authprovider_read";
45+
46+
export type ResourceType = "organization";
47+
48+
export type SubjectType = "user";
49+
50+
export type CheckResult = {
51+
permitted: boolean;
52+
err?: Error;
53+
response?: v1.CheckPermissionResponse;
54+
};
55+
56+
export const NotPermitted = { permitted: false };
57+
58+
export const PermissionChecker = Symbol("PermissionChecker");
59+
60+
export interface PermissionChecker {
61+
check(req: v1.CheckPermissionRequest): Promise<CheckResult>;
62+
}
63+
64+
@injectable()
65+
export class Authorizer implements PermissionChecker {
66+
@inject(SpiceDBClient)
67+
private client: SpiceDBClient;
68+
69+
async check(req: v1.CheckPermissionRequest): Promise<CheckResult> {
70+
if (!this.client) {
71+
return {
72+
permitted: false,
73+
err: new Error("Authorization client not available."),
74+
response: v1.CheckPermissionResponse.create({}),
75+
};
76+
}
77+
78+
const timer = spicedbClientLatency.startTimer();
79+
try {
80+
const response = await this.client.checkPermission(req);
81+
const permitted = response.permissionship === v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION;
82+
const err = !permitted ? newUnathorizedError(req.resource!, req.permission, req.subject!) : undefined;
83+
84+
observeSpicedbClientLatency("check", req.permission, undefined, timer());
85+
86+
return { permitted, response, err };
87+
} catch (err) {
88+
log.error("[spicedb] Failed to perform authorization check.", err, { req });
89+
observeSpicedbClientLatency("check", req.permission, err, timer());
90+
91+
throw err;
92+
}
93+
}
94+
}
95+
96+
function newUnathorizedError(resource: v1.ObjectReference, relation: string, subject: v1.SubjectReference) {
97+
return new ResponseError(
98+
ErrorCodes.PERMISSION_DENIED,
99+
`Subject (${objString(subject.object)}) is not permitted to perform ${relation} on resource ${objString(
100+
resource,
101+
)}.`,
102+
);
103+
}
104+
105+
function objString(obj?: v1.ObjectReference): string {
106+
return `${obj?.objectType}:${obj?.objectId}`;
107+
}

components/server/src/container-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ import { UbpResetOnCancel } from "@gitpod/gitpod-payment-endpoint/lib/chargebee/
113113
import { retryMiddleware } from "nice-grpc-client-middleware-retry";
114114
import { IamSessionApp } from "./iam/iam-session-app";
115115
import { spicedbClientFromEnv, SpiceDBClient } from "./authorization/spicedb";
116+
import { Authorizer, PermissionChecker } from "./authorization/perms";
116117

117118
export const productionContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => {
118119
bind(Config).toConstantValue(ConfigFile.fromFile());
@@ -313,4 +314,5 @@ export const productionContainerModule = new ContainerModule((bind, unbind, isBo
313314
bind(SpiceDBClient)
314315
.toDynamicValue(() => spicedbClientFromEnv())
315316
.inSingletonScope();
317+
bind(PermissionChecker).to(Authorizer).inSingletonScope();
316318
});

components/server/src/prometheus-metrics.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export function registerServerMetrics(registry: prometheusClient.Registry) {
2222
registry.registerMetric(stripeClientRequestsCompletedDurationSeconds);
2323
registry.registerMetric(imageBuildsStartedTotal);
2424
registry.registerMetric(imageBuildsCompletedTotal);
25+
registry.registerMetric(centralizedPermissionsValidationsTotal);
26+
registry.registerMetric(spicedbClientLatency);
2527
}
2628

2729
const loginCounter = new prometheusClient.Counter({
@@ -195,3 +197,31 @@ export const imageBuildsCompletedTotal = new prometheusClient.Counter({
195197
export function increaseImageBuildsCompletedTotal(outcome: "succeeded" | "failed") {
196198
imageBuildsCompletedTotal.inc({ outcome });
197199
}
200+
201+
const centralizedPermissionsValidationsTotal = new prometheusClient.Counter({
202+
name: "gitpod_perms_centralized_validations_total",
203+
help: "counter of centralized permission checks validations against existing system",
204+
labelNames: ["operation", "matches_expectation"],
205+
});
206+
207+
export function reportCentralizedPermsValidation(operation: string, matches: boolean) {
208+
centralizedPermissionsValidationsTotal.inc({ operation, matches_expectation: String(matches) });
209+
}
210+
211+
export const spicedbClientLatency = new prometheusClient.Histogram({
212+
name: "gitpod_spicedb_client_requests_completed_seconds",
213+
help: "Histogram of completed spicedb client requests",
214+
labelNames: ["operation", "permission", "outcome"],
215+
});
216+
217+
export function observespicedbClientLatency(
218+
operation: string,
219+
permission: string,
220+
outcome: Error | undefined,
221+
durationInSeconds: number,
222+
) {
223+
spicedbClientLatency.observe(
224+
{ operation, permission, outcome: outcome === undefined ? "success" : "error" },
225+
durationInSeconds,
226+
);
227+
}

0 commit comments

Comments
 (0)