Skip to content

Commit 1113e3c

Browse files
authored
[authorization] Align HTTP handlers before RequestContext rollout (#19214)
* [middleware] RequestContext: don't error on nested contexts + ctxOnAbort * [auth] HTTP handlers: Add FGA guards and runWithSubjectId where missing * [code-sync] Guard with FGA
1 parent 682878a commit 1113e3c

File tree

11 files changed

+312
-277
lines changed

11 files changed

+312
-277
lines changed

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { RepoURL } from "../repohost";
2424
import { HostContextProvider } from "./host-context-provider";
2525
import { isFgaChecksEnabled } from "../authorization/authorizer";
2626
import { reportGuardAccessCheck } from "../prometheus-metrics";
27+
import { FunctionAccessGuard } from "./function-access";
2728

2829
declare let resourceInstance: GuardedResource;
2930
export type GuardedResourceKind = typeof resourceInstance.kind;
@@ -174,6 +175,26 @@ export class FGAResourceAccessGuard implements ResourceAccessGuard {
174175
}
175176
}
176177

178+
/**
179+
* FGAFunctionAccessGuard can disable the delegate if FGA is enabled.
180+
*/
181+
export class FGAFunctionAccessGuard {
182+
constructor(private readonly userId: string, private readonly delegate: FunctionAccessGuard) {}
183+
184+
async canAccess(name: string): Promise<boolean> {
185+
const authorizerEnabled = await isFgaChecksEnabled(this.userId);
186+
if (authorizerEnabled) {
187+
// Authorizer takes over, so we should not check.
188+
reportGuardAccessCheck("fga");
189+
return true;
190+
}
191+
reportGuardAccessCheck("function-access");
192+
193+
// FGA can't take over yet, so we delegate
194+
return this.delegate.canAccess(name);
195+
}
196+
}
197+
177198
export class TeamMemberResourceGuard implements ResourceAccessGuard {
178199
constructor(readonly userId: string) {}
179200

components/server/src/authorization/definitions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ export type UserPermission =
4343
| "read_tokens"
4444
| "write_tokens"
4545
| "read_env_var"
46-
| "write_env_var";
46+
| "write_env_var"
47+
| "code_sync";
4748

4849
export type InstallationResourceType = "installation";
4950

components/server/src/code-sync/code-sync-service.ts

Lines changed: 45 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import * as util from "util";
1111
import express from "express";
1212
import { inject, injectable } from "inversify";
1313
import { BearerAuth } from "../auth/bearer-authenticator";
14-
import { isWithFunctionAccessGuard } from "../auth/function-access";
14+
import { WithFunctionAccessGuard } from "../auth/function-access";
1515
import { CodeSyncResourceDB, ALL_SERVER_RESOURCES, ServerResource, SyncResource } from "@gitpod/gitpod-db/lib";
1616
import {
1717
DeleteRequest,
@@ -26,6 +26,8 @@ import { v4 as uuidv4 } from "uuid";
2626
import { accessCodeSyncStorage, UserRateLimiter } from "../auth/rate-limiter";
2727
import { Config } from "../config";
2828
import { CachingBlobServiceClientProvider } from "../util/content-service-sugar";
29+
import { Authorizer } from "../authorization/authorizer";
30+
import { FGAFunctionAccessGuard } from "../auth/resource-access";
2931

3032
// By default: 5 kind of resources * 20 revs * 1Mb = 100Mb max in the content service for user data.
3133
const defaultRevLimit = 20;
@@ -71,17 +73,22 @@ function toObjectName(resource: ServerResource, rev: string, collection: string
7173

7274
@injectable()
7375
export class CodeSyncService {
74-
@inject(Config)
75-
private readonly config: Config;
76+
constructor(
77+
@inject(Config)
78+
private readonly config: Config,
7679

77-
@inject(BearerAuth)
78-
private readonly auth: BearerAuth;
80+
@inject(BearerAuth)
81+
private readonly auth: BearerAuth,
7982

80-
@inject(CachingBlobServiceClientProvider)
81-
private readonly blobsProvider: CachingBlobServiceClientProvider;
83+
@inject(CachingBlobServiceClientProvider)
84+
private readonly blobsProvider: CachingBlobServiceClientProvider,
8285

83-
@inject(CodeSyncResourceDB)
84-
private readonly db: CodeSyncResourceDB;
86+
@inject(CodeSyncResourceDB)
87+
private readonly db: CodeSyncResourceDB,
88+
89+
@inject(Authorizer)
90+
private readonly authorizer: Authorizer,
91+
) {}
8592

8693
get apiRouter(): express.Router {
8794
const config = this.config.codeSync;
@@ -91,16 +98,16 @@ export class CodeSyncService {
9198
res.setHeader("x-operation-id", uuidv4());
9299
return next();
93100
});
94-
router.use(this.auth.restHandler);
101+
router.use(this.auth.restHandler); // expects Bearer token and authenticates req.user, throws otherwise
95102
router.use(async (req, res, next) => {
96103
if (!User.is(req.user)) {
97104
res.sendStatus(400);
98105
return;
99106
}
100107

101-
const id = req.user.id;
108+
const userId = req.user.id;
102109
try {
103-
await UserRateLimiter.instance(this.config.rateLimiter).consume(id, accessCodeSyncStorage);
110+
await UserRateLimiter.instance(this.config.rateLimiter).consume(userId, accessCodeSyncStorage);
104111
} catch (e) {
105112
if (e instanceof Error) {
106113
throw e;
@@ -110,20 +117,26 @@ export class CodeSyncService {
110117
return;
111118
}
112119

113-
if (!isWithFunctionAccessGuard(req) || !req.functionGuard?.canAccess(accessCodeSyncStorage)) {
120+
const functionGuard = (req.user as WithFunctionAccessGuard).functionGuard;
121+
if (!!functionGuard) {
122+
// If we authorize with scopes, there is a FunctionGuard
123+
const guard = new FGAFunctionAccessGuard(userId, functionGuard);
124+
if (!(await guard.canAccess(accessCodeSyncStorage))) {
125+
res.sendStatus(403);
126+
return;
127+
}
128+
}
129+
130+
if (!(await this.authorizer.hasPermissionOnUser(userId, "code_sync", userId))) {
114131
res.sendStatus(403);
115132
return;
116133
}
134+
117135
return next();
118136
});
119137

120138
router.get("/v1/manifest", async (req, res) => {
121-
if (!User.is(req.user)) {
122-
res.sendStatus(400);
123-
return;
124-
}
125-
126-
const manifest = await this.db.getManifest(req.user.id);
139+
const manifest = await this.db.getManifest(req.user!.id);
127140
if (!manifest) {
128141
res.sendStatus(204);
129142
return;
@@ -149,13 +162,8 @@ export class CodeSyncService {
149162
router.delete("/v1/collection/:collection/resource/:resource/:ref?", this.deleteResource.bind(this));
150163
router.delete("/v1/resource/:resource/:ref?", this.deleteResource.bind(this));
151164
router.delete("/v1/resource", async (req, res) => {
152-
if (!User.is(req.user)) {
153-
res.sendStatus(400);
154-
return;
155-
}
156-
157165
// This endpoint is used to delete settings-sync data only
158-
const userId = req.user.id;
166+
const userId = req.user!.id;
159167
await this.db.deleteSettingsSyncResources(userId, async () => {
160168
const request = new DeleteRequest();
161169
request.setOwnerId(userId);
@@ -173,36 +181,21 @@ export class CodeSyncService {
173181
});
174182

175183
router.get("/v1/collection", async (req, res) => {
176-
if (!User.is(req.user)) {
177-
res.sendStatus(400);
178-
return;
179-
}
180-
181-
const collections = await this.db.getCollections(req.user.id);
184+
const collections = await this.db.getCollections(req.user!.id);
182185

183186
res.json(collections);
184187
return;
185188
});
186189
router.post("/v1/collection", async (req, res) => {
187-
if (!User.is(req.user)) {
188-
res.sendStatus(400);
189-
return;
190-
}
191-
192-
const collection = await this.db.createCollection(req.user.id);
190+
const collection = await this.db.createCollection(req.user!.id);
193191

194192
res.type("text/plain");
195193
res.send(collection);
196194
return;
197195
});
198196
router.delete("/v1/collection/:collection?", async (req, res) => {
199-
if (!User.is(req.user)) {
200-
res.sendStatus(400);
201-
return;
202-
}
203-
204197
const { collection } = req.params;
205-
await this.deleteCollection(req.user.id, collection);
198+
await this.deleteCollection(req.user!.id, collection);
206199

207200
res.sendStatus(200);
208201
});
@@ -211,11 +204,6 @@ export class CodeSyncService {
211204
}
212205

213206
private async getResources(req: express.Request<{ resource: string; collection?: string }>, res: express.Response) {
214-
if (!User.is(req.user)) {
215-
res.sendStatus(400);
216-
return;
217-
}
218-
219207
const { resource, collection } = req.params;
220208
const resourceKey = ALL_SERVER_RESOURCES.find((key) => key === resource);
221209
if (!resourceKey) {
@@ -224,14 +212,14 @@ export class CodeSyncService {
224212
}
225213

226214
if (collection) {
227-
const valid = await this.db.isCollection(req.user.id, collection);
215+
const valid = await this.db.isCollection(req.user!.id, collection);
228216
if (!valid) {
229217
res.sendStatus(405);
230218
return;
231219
}
232220
}
233221

234-
const revs = await this.db.getResources(req.user.id, resourceKey, collection);
222+
const revs = await this.db.getResources(req.user!.id, resourceKey, collection);
235223
if (!revs.length) {
236224
res.sendStatus(204);
237225
return;
@@ -249,11 +237,6 @@ export class CodeSyncService {
249237
req: express.Request<{ resource: string; ref: string; collection?: string }>,
250238
res: express.Response,
251239
) {
252-
if (!User.is(req.user)) {
253-
res.sendStatus(400);
254-
return;
255-
}
256-
257240
const { resource, ref, collection } = req.params;
258241
const resourceKey = ALL_SERVER_RESOURCES.find((key) => key === resource);
259242
if (!resourceKey) {
@@ -262,14 +245,14 @@ export class CodeSyncService {
262245
}
263246

264247
if (collection) {
265-
const valid = await this.db.isCollection(req.user.id, collection);
248+
const valid = await this.db.isCollection(req.user!.id, collection);
266249
if (!valid) {
267250
res.sendStatus(405);
268251
return;
269252
}
270253
}
271254

272-
const resourceRev = (await this.db.getResource(req.user.id, resourceKey, ref, collection))?.rev;
255+
const resourceRev = (await this.db.getResource(req.user!.id, resourceKey, ref, collection))?.rev;
273256
if (!resourceRev) {
274257
res.setHeader("etag", "0");
275258
res.sendStatus(204);
@@ -283,7 +266,7 @@ export class CodeSyncService {
283266
let content: string;
284267
const contentType = req.headers["content-type"] || "*/*";
285268
const request = new DownloadUrlRequest();
286-
request.setOwnerId(req.user.id);
269+
request.setOwnerId(req.user!.id);
287270
request.setName(toObjectName(resourceKey, resourceRev, collection));
288271
request.setContentType(contentType);
289272
try {
@@ -317,11 +300,6 @@ export class CodeSyncService {
317300
}
318301

319302
private async postResource(req: express.Request<{ resource: string; collection?: string }>, res: express.Response) {
320-
if (!User.is(req.user)) {
321-
res.sendStatus(400);
322-
return;
323-
}
324-
325303
const { resource, collection } = req.params;
326304
const resourceKey = ALL_SERVER_RESOURCES.find((key) => key === resource);
327305
if (!resourceKey) {
@@ -330,7 +308,7 @@ export class CodeSyncService {
330308
}
331309

332310
if (collection) {
333-
const valid = await this.db.isCollection(req.user.id, collection);
311+
const valid = await this.db.isCollection(req.user!.id, collection);
334312
if (!valid) {
335313
res.sendStatus(405);
336314
return;
@@ -346,7 +324,7 @@ export class CodeSyncService {
346324
this.config.codeSync?.revLimit ||
347325
defaultRevLimit;
348326
const isEditSessionsResource = resourceKey === "editSessions";
349-
const userId = req.user.id;
327+
const userId = req.user!.id;
350328
const contentType = req.headers["content-type"] || "*/*";
351329
const newRev = await this.db.insert(
352330
userId,
@@ -403,11 +381,6 @@ export class CodeSyncService {
403381
req: express.Request<{ resource: string; ref?: string; collection?: string }>,
404382
res: express.Response,
405383
) {
406-
if (!User.is(req.user)) {
407-
res.sendStatus(400);
408-
return;
409-
}
410-
411384
// This endpoint is used to delete edit sessions data for now
412385

413386
const { resource, ref, collection } = req.params;
@@ -418,14 +391,14 @@ export class CodeSyncService {
418391
}
419392

420393
if (collection) {
421-
const valid = await this.db.isCollection(req.user.id, collection);
394+
const valid = await this.db.isCollection(req.user!.id, collection);
422395
if (!valid) {
423396
res.sendStatus(405);
424397
return;
425398
}
426399
}
427400

428-
await this.doDeleteResource(req.user.id, resourceKey, ref, collection);
401+
await this.doDeleteResource(req.user!.id, resourceKey, ref, collection);
429402
res.sendStatus(200);
430403
return;
431404
}

components/server/src/prometheus-metrics.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ export const guardAccessChecksTotal = new prometheusClient.Counter({
350350
labelNames: ["type"],
351351
});
352352

353-
export type GuardAccessCheckType = "fga" | "resource-access";
353+
export type GuardAccessCheckType = "fga" | "resource-access" | "function-access";
354354
export function reportGuardAccessCheck(type: GuardAccessCheckType) {
355355
guardAccessChecksTotal.labels(type).inc();
356356
}

components/server/src/server.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ import { JobRunner } from "./jobs/runner";
4848
import { RedisSubscriber } from "./messaging/redis-subscriber";
4949
import { HEADLESS_LOGS_PATH_PREFIX, HEADLESS_LOG_DOWNLOAD_PATH_PREFIX } from "./workspace/headless-log-service";
5050
import { runWithRequestContext } from "./util/request-context";
51-
import { SubjectId } from "./auth/subject-id";
5251

5352
@injectable()
5453
export class Server {
@@ -143,13 +142,14 @@ export class Server {
143142

144143
// Use RequestContext for authorization
145144
app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
146-
const userId = req.user ? req.user.id : undefined;
145+
const abortController = new AbortController();
146+
req.on("abort", () => abortController.abort());
147147
runWithRequestContext(
148148
{
149149
requestKind: "http",
150150
requestMethod: req.path,
151-
signal: new AbortController().signal,
152-
subjectId: userId ? SubjectId.fromUserId(userId) : undefined, // TODO(gpl) Can we assume this? E.g., has this been verified? It should: It means we could decode the cookie, right?
151+
signal: abortController.signal,
152+
subjectId: undefined, // Don't use req.user, as that would elevate permissions once we rollout scope-based API tokens
153153
headers: toHeaders(req.headers),
154154
},
155155
() => next(),
@@ -296,19 +296,27 @@ export class Server {
296296
}
297297

298298
protected async registerRoutes(app: express.Application) {
299+
// Authorization: Session only
299300
app.use(this.userController.apiRouter);
300-
app.use(this.oneTimeSecretServer.apiRouter);
301301
app.use("/workspace-download", this.workspaceDownloadService.apiRouter);
302-
app.use("/code-sync", this.codeSyncService.apiRouter);
302+
app.use(this.oauthController.oauthRouter);
303+
304+
// Authorization: Session or Bearer token
303305
app.use(HEADLESS_LOGS_PATH_PREFIX, this.headlessLogController.headlessLogs);
304306
app.use(HEADLESS_LOG_DOWNLOAD_PATH_PREFIX, this.headlessLogController.headlessLogDownload);
305-
app.use("/live", this.livenessController.apiRouter);
307+
308+
// Authorization: Bearer token only
309+
app.use("/code-sync", this.codeSyncService.apiRouter);
310+
311+
// Authorization: none
312+
app.use(this.oneTimeSecretServer.apiRouter);
306313
app.use(this.newsletterSubscriptionController.apiRouter);
314+
app.use("/live", this.livenessController.apiRouter);
307315
app.use("/version", (req: express.Request, res: express.Response, next: express.NextFunction) => {
308316
res.send(this.config.version);
309317
});
310-
app.use(this.oauthController.oauthRouter);
311318

319+
// Authorization: Custom
312320
if (this.config.githubApp?.enabled && this.githubApp.server) {
313321
log.info("Registered GitHub app at /apps/github");
314322
app.use("/apps/github/", this.githubApp.server?.expressApp);

0 commit comments

Comments
 (0)