Skip to content

[server] SpiceDB: Add request-level caching based on AsyncLocalStorage+ZedTokens #18893

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Oct 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions components/BUILD.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ scripts:
srcs:
- components/**/*
script: |
GO_COMPONENTS=( local-app-api content-service-api image-builder-api registry-facade-api supervisor-api ws-daemon-api ws-manager-api ws-manager-bridge-api usage-api )
GO_COMPONENTS=( local-app-api content-service-api image-builder-api registry-facade-api supervisor-api ws-daemon-api ws-manager-api ws-manager-bridge-api usage-api spicedb )

for COMPONENT in "${GO_COMPONENTS[@]}";do
echo "Generating code for component $COMPONENT..."
Expand All @@ -157,7 +157,7 @@ scripts:
popd > /dev/null
done

WEB_COMPONENTS=( local-app-api/typescript-grpcweb supervisor-api/typescript-grpc supervisor-api/typescript-grpcweb )
WEB_COMPONENTS=( local-app-api/typescript-grpcweb supervisor-api/typescript-grpc supervisor-api/typescript-grpcweb spicedb/typescript )
for COMPONENT in "${WEB_COMPONENTS[@]}";do
echo "Generating code for component $COMPONENT..."
pushd $COMPONENT > /dev/null
Expand Down
1 change: 1 addition & 0 deletions components/server/BUILD.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ packages:
- components/public-api/typescript:lib
- components/gitpod-db:dbtest-init
- components/spicedb:lib
- components/spicedb/typescript:lib
config:
packaging: offline-mirror
yarnLock: ${coreYarnLockBase}/yarn.lock
Expand Down
1 change: 1 addition & 0 deletions components/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"@gitpod/supervisor-api-grpcweb": "0.1.5",
"@gitpod/usage-api": "0.1.5",
"@gitpod/ws-manager": "0.1.5",
"@gitpod/spicedb-impl": "0.1.5",
"@google-cloud/profiler": "^6.0.0",
"@improbable-eng/grpc-web-node-http-transport": "^0.14.0",
"@jmondi/oauth2-server": "^2.6.1",
Expand Down
15 changes: 10 additions & 5 deletions components/server/src/authorization/authorizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ export class Authorizer {
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 Down Expand Up @@ -98,7 +99,8 @@ export class Authorizer {
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 Down Expand Up @@ -128,7 +130,8 @@ export class Authorizer {
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 Down Expand Up @@ -158,7 +161,8 @@ export class Authorizer {
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 @@ -192,7 +196,8 @@ export class Authorizer {
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/**
* 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.
*/
import { TypeORM } from "@gitpod/gitpod-db/lib";
import { resetDB } from "@gitpod/gitpod-db/lib/test/reset-db";
import { CommitContext, Organization, Project, User, WorkspaceConfig } from "@gitpod/gitpod-protocol";
import { Experiments } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server";
import * as chai from "chai";
import { Container } from "inversify";
import "mocha";
import { createTestContainer } from "../test/service-testing-container-module";
import { Authorizer, SYSTEM_USER } from "./authorizer";
import { OrganizationService } from "../orgs/organization-service";
import { WorkspaceService } from "../workspace/workspace-service";
import { UserService } from "../user/user-service";
import { ZedTokenCache } from "./caching-spicedb-authorizer";
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
import { ConfigProvider } from "../workspace/config-provider";
import { runWithContext } from "../util/log-context";

const expect = chai.expect;

const withCtx = <T>(p: Promise<T>) => runWithContext("test", {}, () => p);

describe("CachingSpiceDBAuthorizer", async () => {
let container: Container;
let userSvc: UserService;
let orgSvc: OrganizationService;
let workspaceSvc: WorkspaceService;
let authorizer: Authorizer;
let zedTokenCache: ZedTokenCache;

beforeEach(async () => {
container = createTestContainer();
// TODO(gpl) Ideally we should be able to factor this out into the API. But to start somewhere, we'll mock it out here.
container.rebind(ConfigProvider).toConstantValue({
fetchConfig: () => ({
config: <WorkspaceConfig>{
image: "gitpod/workspace-full:latest",
},
}),
} as any as ConfigProvider);
Experiments.configureTestingClient({
centralizedPermissions: true,
});
userSvc = container.get<UserService>(UserService);
orgSvc = container.get<OrganizationService>(OrganizationService);
workspaceSvc = container.get<WorkspaceService>(WorkspaceService);
authorizer = container.get<Authorizer>(Authorizer);
zedTokenCache = container.get<ZedTokenCache>(ZedTokenCache);
});

afterEach(async () => {
// Clean-up database
await resetDB(container.get(TypeORM));

container.unbindAll();
});

it("should avoid new-enemy after removal", async () => {
// userB and userC are members of org1, userA is owner.
// All users are installation owned.
const org1 = await withCtx(orgSvc.createOrganization(SYSTEM_USER, "org1"));
const userA = await withCtx(
userSvc.createUser({
organizationId: undefined,
identity: {
authProviderId: "github",
authId: "123",
authName: "userA",
},
}),
);
await withCtx(orgSvc.addOrUpdateMember(SYSTEM_USER, org1.id, userA.id, "owner"));
const userB = await withCtx(
userSvc.createUser({
organizationId: undefined,
identity: {
authProviderId: "github",
authId: "456",
authName: "userB",
},
}),
);
await withCtx(orgSvc.addOrUpdateMember(SYSTEM_USER, org1.id, userB.id, "member"));
const userC = await withCtx(
userSvc.createUser({
organizationId: undefined,
identity: {
authProviderId: "github",
authId: "789",
authName: "userC",
},
}),
);
await withCtx(orgSvc.addOrUpdateMember(SYSTEM_USER, org1.id, userC.id, "member"));

// userA creates a workspace when userB is still member of the org
// All members have "read_info" (derived from membership)
const ws1 = await withCtx(createTestWorkspace(org1, userA));

expect(
await withCtx(authorizer.hasPermissionOnWorkspace(userB.id, "read_info", ws1.id)),
"userB should have read_info after removal",
).to.be.true;
expect(
await withCtx(authorizer.hasPermissionOnWorkspace(userA.id, "read_info", ws1.id)),
"userA should have read_info after removal of userB",
).to.be.true;
expect(
await withCtx(authorizer.hasPermissionOnWorkspace(userC.id, "read_info", ws1.id)),
"userC should have read_info after removal of userB",
).to.be.true;

// INTERNALS
async function printTokens(): Promise<{ ws1Token: string | undefined; org1Token: string | undefined }> {
const ws1Token = await zedTokenCache.get({ objectType: "workspace", objectId: ws1.id });
log.info("ws1Token", ws1Token);
const org1Token = await zedTokenCache.get({ objectType: "organization", objectId: org1.id });
log.info("org1Token", org1Token);
return { ws1Token, org1Token };
}
const { org1Token: org1TokenT1 } = await printTokens();

// userB is removed from the org
await withCtx(orgSvc.removeOrganizationMember(SYSTEM_USER, org1.id, userB.id));

// INTERNALS
const { org1Token: org1TokenT2 } = await printTokens();
expect(org1TokenT1 === org1TokenT2 && org1TokenT1 !== undefined && org1TokenT2 !== undefined).to.be.false;

expect(
await withCtx(authorizer.hasPermissionOnWorkspace(userB.id, "read_info", ws1.id)),
"userB should have read_info after removal",
).to.be.false;
expect(
await withCtx(authorizer.hasPermissionOnWorkspace(userA.id, "read_info", ws1.id)),
"userA should have read_info after removal of userB",
).to.be.true;
expect(
await withCtx(authorizer.hasPermissionOnWorkspace(userC.id, "read_info", ws1.id)),
"userC should have read_info after removal of userB",
).to.be.true;
});

async function createTestWorkspace(org: Organization, owner: User, project?: Project) {
const ws = await workspaceSvc.createWorkspace(
{},
owner,
org.id,
project,
<CommitContext>{
title: "gitpod",
repository: {
host: "github.com",
owner: "gitpod-io",
name: "gitpod",
cloneUrl: "https://github.com/gitpod-io/gitpod.git",
},
revision: "asdf",
},
"github.com/gitpod-io/gitpod",
);
return ws;
}

it("should avoid read-your-writes problem when adding a new user", async () => {
// userB and userC are members of org1, userA is owner.
// All users are installation owned.
const org1 = await withCtx(orgSvc.createOrganization(SYSTEM_USER, "org1"));
const userA = await withCtx(
userSvc.createUser({
organizationId: undefined,
identity: {
authProviderId: "github",
authId: "123",
authName: "userA",
},
}),
);
await withCtx(orgSvc.addOrUpdateMember(SYSTEM_USER, org1.id, userA.id, "owner"));
const userC = await withCtx(
userSvc.createUser({
organizationId: undefined,
identity: {
authProviderId: "github",
authId: "789",
authName: "userC",
},
}),
);
await withCtx(orgSvc.addOrUpdateMember(SYSTEM_USER, org1.id, userC.id, "member"));

// userA creates a workspace before userB is member of the org
const ws1 = await withCtx(createTestWorkspace(org1, userA));

expect(
await withCtx(authorizer.hasPermissionOnWorkspace(userA.id, "read_info", ws1.id)),
"userA should have read_info after removal of userB",
).to.be.true;
expect(
await withCtx(authorizer.hasPermissionOnWorkspace(userC.id, "read_info", ws1.id)),
"userC should have read_info after removal of userB",
).to.be.true;

// userB is added to the org
const userB = await withCtx(
userSvc.createUser({
organizationId: undefined,
identity: {
authProviderId: "github",
authId: "456",
authName: "userB",
},
}),
);
await withCtx(orgSvc.addOrUpdateMember(SYSTEM_USER, org1.id, userB.id, "member"));

expect(
await withCtx(authorizer.hasPermissionOnWorkspace(userB.id, "read_info", ws1.id)),
"userB should have read_info after removal",
).to.be.true;
expect(
await withCtx(authorizer.hasPermissionOnWorkspace(userA.id, "read_info", ws1.id)),
"userA should have read_info after removal of userB",
).to.be.true;
expect(
await withCtx(authorizer.hasPermissionOnWorkspace(userC.id, "read_info", ws1.id)),
"userC should have read_info after removal of userB",
).to.be.true;
});
});
Loading