diff --git a/components/gitpod-protocol/src/object-id.ts b/components/gitpod-protocol/src/object-id.ts new file mode 100644 index 00000000000000..07d04ca0362ebf --- /dev/null +++ b/components/gitpod-protocol/src/object-id.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2023 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 ObjectId = { + kind: ObjectKind; + value: string; +}; +export type ObjectKind = keyof typeof ObjectKindNames; +const ObjectKindNames = { + installation: "inst", + user: "user", + organization: "org", + project: "proj", + workspace: "ws", +}; +export const ObjectKindByShortName: ReadonlyMap = new Map( + Object.keys(ObjectKindNames).map((k) => { + return [ObjectKindNames[k as ObjectKind], k as ObjectKind]; + }), +); + +export namespace ObjectId { + const SEPARATOR = "_"; + export function create(kind: ObjectKind, value: string): ObjectId { + switch (kind) { + case "installation": + return { kind, value }; + case "user": + return { kind, value }; + case "organization": + return { kind, value }; + case "project": + return { kind, value }; + case "workspace": + return { kind, value }; + } + } + export function isObjectKind(str: string): str is ObjectKind { + return !!ObjectKindNames[str as ObjectKind]; + } + export function toString(id: ObjectId): string { + const prefix = ObjectKindNames[id.kind]; + return prefix + SEPARATOR + id.value; + } + export function tryParse(str: string): ObjectId { + const parts = str.split(SEPARATOR); + if (parts.length < 2) { + throw new Error(`Unable to parse ObjectId`); + } + const kind = ObjectKindByShortName.get(parts[0]); + if (!kind) { + throw new Error(`Unable to parse ObjectId: unknown objectKind!`); + } + const value = parts.slice(1).join(); + return { kind, value }; + } +} diff --git a/components/server/src/authorization/authorizer.ts b/components/server/src/authorization/authorizer.ts index 3ec1bc6d3de3c9..0ce48c983bcd91 100644 --- a/components/server/src/authorization/authorizer.ts +++ b/components/server/src/authorization/authorizer.ts @@ -66,10 +66,10 @@ export class Authorizer { subject: subject("user", userId), permission, resource: object("installation", InstallationID), - consistency, }); - return this.authorizer.check(req, { userId }); + const result = await this.authorizer.check(req, { userId }); + return result.permitted; } async checkPermissionOnInstallation(userId: string, permission: InstallationPermission): Promise { @@ -95,10 +95,10 @@ export class Authorizer { subject: subject("user", userId), permission, resource: object("organization", orgId), - consistency, }); - return this.authorizer.check(req, { userId }); + const result = await this.authorizer.check(req, { userId }); + return result.permitted; } async checkPermissionOnOrganization(userId: string, permission: OrganizationPermission, orgId: string) { @@ -125,10 +125,10 @@ export class Authorizer { subject: subject("user", userId), permission, resource: object("project", projectId), - consistency, }); - return this.authorizer.check(req, { userId }); + const result = await this.authorizer.check(req, { userId }); + return result.permitted; } async checkPermissionOnProject(userId: string, permission: ProjectPermission, projectId: string) { @@ -155,10 +155,10 @@ export class Authorizer { subject: subject("user", userId), permission, resource: object("user", resourceUserId), - consistency, }); - return this.authorizer.check(req, { userId }); + const result = await this.authorizer.check(req, { userId }); + return result.permitted; } async checkPermissionOnUser(userId: string, permission: UserPermission, resourceUserId: string) { @@ -189,10 +189,10 @@ export class Authorizer { subject: subject("user", userId), permission, resource: object("workspace", workspaceId), - consistency, }); - return this.authorizer.check(req, { userId }, forceEnablement); + const result = await this.authorizer.check(req, { userId }, forceEnablement); + return result.permitted; } async checkPermissionOnWorkspace(userId: string, permission: WorkspacePermission, workspaceId: string) { @@ -582,13 +582,6 @@ function subject(type: ResourceType, id?: string, relation?: Relation | Permissi }); } -const consistency = v1.Consistency.create({ - requirement: { - oneofKind: "fullyConsistent", - fullyConsistent: true, - }, -}); - function asSet(array: (T | undefined)[]): Set { const result = new Set(); for (const r of array) { @@ -598,3 +591,10 @@ function asSet(array: (T | undefined)[]): Set { } return result; } + +function fromResource(resource: v1.ObjectReference): ObjectId { + if (!ObjectId.isObjectKind(resource.objectType)) { + throw new Error("Unknown object kind: " + resource.objectType); + } + return ObjectId.create(resource.objectType, resource.objectId); +} diff --git a/components/server/src/authorization/spicedb-authorizer.ts b/components/server/src/authorization/spicedb-authorizer.ts index 2dfbd81f2ca8f0..12ab9e64ac9c8f 100644 --- a/components/server/src/authorization/spicedb-authorizer.ts +++ b/components/server/src/authorization/spicedb-authorizer.ts @@ -13,6 +13,7 @@ import { observeSpicedbClientLatency, spicedbClientLatency } from "../prometheus import { SpiceDBClientProvider } from "./spicedb"; import * as grpc from "@grpc/grpc-js"; import { isFgaChecksEnabled, isFgaWritesEnabled } from "./authorizer"; +import { InstallationID } from "./definitions"; async function tryThree(errMessage: string, code: (attempt: number) => Promise): Promise { let attempt = 0; @@ -38,8 +39,27 @@ async function tryThree(errMessage: string, code: (attempt: number) => Promis throw new Error("unreachable"); } +export const SpiceDBAuthorizer = Symbol("SpiceDBAuthorizer"); +export interface SpiceDBAuthorizer { + check( + req: v1.CheckPermissionRequest, + experimentsFields: { + userId: string; + }, + forceEnablement?: boolean, + ): Promise; + writeRelationships(...updates: v1.RelationshipUpdate[]): Promise; + deleteRelationships(req: v1.DeleteRelationshipsRequest): Promise; + readRelationships(req: v1.ReadRelationshipsRequest): Promise; +} + +export interface CheckResult { + permitted: boolean; + checkedAt?: string; +} + @injectable() -export class SpiceDBAuthorizer { +export class SpiceDBAuthorizerImpl implements SpiceDBAuthorizer { constructor( @inject(SpiceDBClientProvider) private readonly clientProvider: SpiceDBClientProvider, @@ -55,9 +75,9 @@ export class SpiceDBAuthorizer { userId: string; }, forceEnablement?: boolean, - ): Promise { + ): Promise { if (!(await isFgaWritesEnabled(experimentsFields.userId))) { - return true; + return { permitted: true }; } const featureEnabled = !!forceEnablement || (await isFgaChecksEnabled(experimentsFields.userId)); const result = (async () => { @@ -73,23 +93,23 @@ export class SpiceDBAuthorizer { response: new TrustedValue(response), request: new TrustedValue(req), }); - return true; + return { permitted: true, checkedAt: response.checkedAt?.token }; } - return permitted; + return { permitted, checkedAt: response.checkedAt?.token }; } catch (err) { error = err; log.error("[spicedb] Failed to perform authorization check.", err, { request: new TrustedValue(req), }); - return !featureEnabled; + return { permitted: !featureEnabled }; } finally { observeSpicedbClientLatency("check", error, timer()); } })(); // if the feature is not enabld, we don't await if (!featureEnabled) { - return true; + return { permitted: true }; } return result; } @@ -164,3 +184,88 @@ export class SpiceDBAuthorizer { }) as any as grpc.Metadata; } } + +@injectable() +export class SpiceDBAuthorizerWithZedTokens implements SpiceDBAuthorizer { + constructor(private readonly impl: SpiceDBAuthorizerImpl, private readonly tokenCache: HierachicalZedTokenCache) {} + + async check( + req: v1.CheckPermissionRequest, + experimentsFields: { userId: string }, + forceEnablement?: boolean | undefined, + ): Promise { + req.consistency = await this.consistency(req.resource); + return this.impl.check(req, experimentsFields, forceEnablement); + } + + async writeRelationships(...updates: v1.RelationshipUpdate[]): Promise { + const result = await this.impl.writeRelationships(...updates); + await this.tokenCache.setBulk(updates.map((u) => [u.relationship?.resource, result?.writtenAt?.token])); + return result; + } + + deleteRelationships(req: v1.DeleteRelationshipsRequest): Promise {} + + readRelationships(req: v1.ReadRelationshipsRequest): Promise {} + + private async consistency(resourceRef: v1.ObjectReference | undefined): Promise { + function fullyConsistent() { + return v1.Consistency.create({ + requirement: { + oneofKind: "fullyConsistent", + fullyConsistent: true, + }, + }); + } + + if (!resourceRef) { + return fullyConsistent(); + } + + const zedToken = await this.tokenCache.get(resourceRef); + if (!zedToken) { + return fullyConsistent(); + } + return v1.Consistency.create({ + requirement: { + oneofKind: "atLeastAsFresh", + atLeastAsFresh: v1.ZedToken.create({ + token: zedToken, + }), + }, + }); + } +} + +/** + * The entities in SpiceDB form a hierarchy, with "installation" at the top, and "workspace" and "user" at the bottom, for instance. + * This cache guarantees that for every requested entity, it always returns the _most recent ZedToken along the path to the bottom_. + */ +export class HierachicalZedTokenCache { + private readonly cache = new Map(); + + async get(objectId: ObjectId): Promise { + return token; + } + + async set(objectId: ObjectId): Promise { + // TODO + return undefined; + } + + async setBulk(objectId: ObjectId): Promise { + // TODO + return undefined; + } + + private buildPath(objectRef: v1.ObjectReference): string { + const theInstallation = v1.ObjectReference.create({ objectType: "installation", objectId: InstallationID }); + switch (objectRef.objectType) { + case "installation": + return `installation:${objectRef.objectId}`; + case "user": + // TODO(gpl): depends on orgId! + return `${this.buildPath(theInstallation)}/user:${objectRef.objectId}`; + } + } +} diff --git a/components/server/src/container-module.ts b/components/server/src/container-module.ts index 05f2f2c3183672..007c17f9e9099a 100644 --- a/components/server/src/container-module.ts +++ b/components/server/src/container-module.ts @@ -51,7 +51,7 @@ import { Authorizer, createInitializingAuthorizer } from "./authorization/author import { RelationshipUpdater } from "./authorization/relationship-updater"; import { RelationshipUpdateJob } from "./authorization/relationship-updater-job"; import { SpiceDBClientProvider, spiceDBConfigFromEnv } from "./authorization/spicedb"; -import { SpiceDBAuthorizer } from "./authorization/spicedb-authorizer"; +import { SpiceDBAuthorizer, SpiceDBAuthorizerImpl } from "./authorization/spicedb-authorizer"; import { BillingModes } from "./billing/billing-mode"; import { EntitlementService, EntitlementServiceImpl } from "./billing/entitlement-service"; import { EntitlementServiceUBP } from "./billing/entitlement-service-ubp"; @@ -317,7 +317,7 @@ export const productionContainerModule = new ContainerModule( ); }) .inSingletonScope(); - bind(SpiceDBAuthorizer).toSelf().inSingletonScope(); + bind(SpiceDBAuthorizer).to(SpiceDBAuthorizerImpl).inSingletonScope(); bind(Authorizer) .toDynamicValue((ctx) => { const authorizer = ctx.container.get(SpiceDBAuthorizer);