Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
geropl committed Oct 4, 2023
1 parent 1325935 commit 301ad83
Show file tree
Hide file tree
Showing 4 changed files with 191 additions and 26 deletions.
60 changes: 60 additions & 0 deletions components/gitpod-protocol/src/object-id.ts
Original file line number Diff line number Diff line change
@@ -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<string, ObjectKind> = 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 };
}
}
34 changes: 17 additions & 17 deletions components/server/src/authorization/authorizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<T>(array: (T | undefined)[]): Set<T> {
const result = new Set<T>();
for (const r of array) {
Expand All @@ -598,3 +591,10 @@ function asSet<T>(array: (T | undefined)[]): Set<T> {
}
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);
}
119 changes: 112 additions & 7 deletions components/server/src/authorization/spicedb-authorizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(errMessage: string, code: (attempt: number) => Promise<T>): Promise<T> {
let attempt = 0;
Expand All @@ -38,8 +39,27 @@ async function tryThree<T>(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<CheckResult>;
writeRelationships(...updates: v1.RelationshipUpdate[]): Promise<v1.WriteRelationshipsResponse | undefined>;
deleteRelationships(req: v1.DeleteRelationshipsRequest): Promise<v1.ReadRelationshipsResponse[]>;
readRelationships(req: v1.ReadRelationshipsRequest): Promise<v1.ReadRelationshipsResponse[]>;
}

export interface CheckResult {
permitted: boolean;
checkedAt?: string;
}

@injectable()
export class SpiceDBAuthorizer {
export class SpiceDBAuthorizerImpl implements SpiceDBAuthorizer {
constructor(
@inject(SpiceDBClientProvider)
private readonly clientProvider: SpiceDBClientProvider,
Expand All @@ -55,9 +75,9 @@ export class SpiceDBAuthorizer {
userId: string;
},
forceEnablement?: boolean,
): Promise<boolean> {
): Promise<CheckResult> {
if (!(await isFgaWritesEnabled(experimentsFields.userId))) {
return true;
return { permitted: true };
}
const featureEnabled = !!forceEnablement || (await isFgaChecksEnabled(experimentsFields.userId));
const result = (async () => {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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<CheckResult> {
req.consistency = await this.consistency(req.resource);
return this.impl.check(req, experimentsFields, forceEnablement);
}

async writeRelationships(...updates: v1.RelationshipUpdate[]): Promise<v1.WriteRelationshipsResponse | undefined> {
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<v1.ReadRelationshipsResponse[]> {}

readRelationships(req: v1.ReadRelationshipsRequest): Promise<v1.ReadRelationshipsResponse[]> {}

private async consistency(resourceRef: v1.ObjectReference | undefined): Promise<v1.Consistency> {
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<string, string>();

async get(objectId: ObjectId): Promise<string | undefined> {
return token;
}

async set(objectId: ObjectId): Promise<string | undefined> {
// TODO
return undefined;
}

async setBulk(objectId: ObjectId): Promise<string | undefined> {
// 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}`;
}
}
}
4 changes: 2 additions & 2 deletions components/server/src/container-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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>(SpiceDBAuthorizer);
Expand Down

0 comments on commit 301ad83

Please sign in to comment.