Skip to content

Commit 7d423e3

Browse files
committed
[server] relationship updates
1 parent ff48768 commit 7d423e3

File tree

10 files changed

+296
-15
lines changed

10 files changed

+296
-15
lines changed

components/gitpod-protocol/src/protocol.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,8 @@ export interface AdditionalUserData extends Partial<WorkspaceTimeoutSetting> {
268268
// additional user profile data
269269
profile?: ProfileDetails;
270270
shouldSeeMigrationMessage?: boolean;
271-
271+
// fgaRelationshipsVersion is the version of the spicedb relationships
272+
fgaRelationshipsVersion?: number;
272273
// remembered workspace auto start options
273274
workspaceAutostartOptions?: WorkspaceAutostartOption[];
274275
}

components/gitpod-protocol/src/teams-projects-protocol.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,12 @@ export interface Organization {
142142
deleted?: boolean;
143143
}
144144

145+
export namespace Organization {
146+
export function is(data?: any): data is Organization {
147+
return typeof data === "object" && ["id", "name"].every((p) => p in data);
148+
}
149+
}
150+
145151
export interface OrganizationSettings {
146152
workspaceSharingDisabled?: boolean;
147153
}

components/server/src/authorization/authorizer.ts

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,20 @@
66

77
import { v1 } from "@authzed/authzed-node";
88

9-
import { Organization, Project, TeamMemberInfo, TeamMemberRole } from "@gitpod/gitpod-protocol";
9+
import { Organization, Project, TeamMemberInfo, TeamMemberRole, User } from "@gitpod/gitpod-protocol";
1010
import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
1111
import {
1212
InstallationID,
13+
InstallationRelation,
1314
OrganizationPermission,
15+
OrganizationRelation,
1416
Permission,
1517
ProjectPermission,
18+
ProjectRelation,
1619
Relation,
1720
ResourceType,
1821
UserPermission,
22+
UserRelation,
1923
} from "./definitions";
2024
import { SpiceDBAuthorizer } from "./spicedb-authorizer";
2125
import { BUILTIN_INSTLLATION_ADMIN_USER_ID } from "@gitpod/gitpod-db/lib";
@@ -39,6 +43,24 @@ export function createInitializingAuthorizer(spiceDbAuthorizer: SpiceDBAuthorize
3943
});
4044
}
4145

46+
export const installation = {
47+
type: "installation",
48+
id: InstallationID,
49+
};
50+
51+
export type Resource = typeof installation | User | Organization | Project;
52+
export namespace Resource {
53+
export function getType(res: Resource): ResourceType {
54+
return (res as any).type === "installation"
55+
? "installation"
56+
: User.is(res)
57+
? "user"
58+
: Organization.is(res)
59+
? "organization"
60+
: "project";
61+
}
62+
}
63+
4264
export class Authorizer {
4365
constructor(private authorizer: SpiceDBAuthorizer) {}
4466

@@ -125,15 +147,24 @@ export class Authorizer {
125147

126148
// write operations below
127149

128-
async addUser(userId: string) {
150+
async addUser(userId: string, owningOrgId?: string) {
151+
const updates = [
152+
v1.RelationshipUpdate.create({
153+
operation: v1.RelationshipUpdate_Operation.TOUCH,
154+
relationship: relationship(objectRef("user", userId), "self", subject("user", userId)),
155+
}),
156+
v1.RelationshipUpdate.create({
157+
operation: v1.RelationshipUpdate_Operation.TOUCH,
158+
relationship: relationship(
159+
objectRef("user", userId),
160+
"container",
161+
owningOrgId ? subject("organization", owningOrgId) : subject("installation", InstallationID),
162+
),
163+
}),
164+
];
129165
await this.authorizer.writeRelationships(
130166
v1.WriteRelationshipsRequest.create({
131-
updates: [
132-
v1.RelationshipUpdate.create({
133-
operation: v1.RelationshipUpdate_Operation.TOUCH,
134-
relationship: relationship(objectRef("user", userId), "self", subject("user", userId)),
135-
}),
136-
],
167+
updates,
137168
}),
138169
);
139170
}
@@ -375,6 +406,76 @@ export class Authorizer {
375406
}),
376407
];
377408
}
409+
410+
public async readRelationships(
411+
inst: typeof installation,
412+
relation: InstallationRelation,
413+
target: Resource,
414+
): Promise<Relationship[]>;
415+
public async readRelationships(user: User, relation: UserRelation, target: Resource): Promise<Relationship[]>;
416+
public async readRelationships(
417+
org: Organization,
418+
relation: OrganizationRelation,
419+
target: Resource,
420+
): Promise<Relationship[]>;
421+
public async readRelationships(
422+
project: Project,
423+
relation: ProjectRelation,
424+
target: Resource,
425+
): Promise<Relationship[]>;
426+
public async readRelationships(subject: Resource, relation?: Relation, object?: Resource): Promise<Relationship[]>;
427+
public async readRelationships(subject: Resource, relation?: Relation, object?: Resource): Promise<Relationship[]> {
428+
const relationShips = await this.authorizer.readRelationships({
429+
consistency: v1.Consistency.create({
430+
requirement: {
431+
oneofKind: "fullyConsistent",
432+
fullyConsistent: true,
433+
},
434+
}),
435+
relationshipFilter: {
436+
resourceType: Resource.getType(subject),
437+
optionalResourceId: subject.id,
438+
optionalRelation: relation || "",
439+
optionalSubjectFilter: object && {
440+
subjectType: Resource.getType(object),
441+
optionalSubjectId: object?.id,
442+
},
443+
},
444+
});
445+
return relationShips
446+
.map((rel) => {
447+
const subject = rel.relationship?.subject?.object;
448+
const object = rel.relationship?.resource;
449+
const relation = rel.relationship?.relation;
450+
if (!subject || !object || !relation) {
451+
throw new Error("Invalid relationship");
452+
}
453+
return new Relationship(
454+
object.objectType as ResourceType,
455+
object.objectId!,
456+
relation as Relation,
457+
subject.objectType as ResourceType,
458+
subject.objectId!,
459+
);
460+
})
461+
.sort((a, b) => {
462+
return a.toString().localeCompare(b.toString());
463+
});
464+
}
465+
}
466+
467+
export class Relationship {
468+
constructor(
469+
public readonly subjectType: ResourceType,
470+
public readonly subjectID: string,
471+
public readonly relation: Relation,
472+
public readonly objectType: ResourceType,
473+
public readonly objectID: string,
474+
) {}
475+
476+
public toString(): string {
477+
return `${this.subjectType}:${this.subjectID}#${this.relation}@${this.objectType}:${this.objectID}`;
478+
}
378479
}
379480

380481
function objectRef(type: ResourceType, id: string): v1.ObjectReference {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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 { BUILTIN_INSTLLATION_ADMIN_USER_ID, TeamDB, TypeORM, UserDB } from "@gitpod/gitpod-db/lib";
8+
import { resetDB } from "@gitpod/gitpod-db/lib/test/reset-db";
9+
import { User } from "@gitpod/gitpod-protocol";
10+
import { Experiments } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server";
11+
import * as chai from "chai";
12+
import { Container } from "inversify";
13+
import "mocha";
14+
import { createTestContainer } from "../test/service-testing-container-module";
15+
import { Authorizer, Relationship, Resource, installation } from "./authorizer";
16+
import { Relation } from "./definitions";
17+
import { RelationshipUpdater } from "./relationship-updater";
18+
19+
const expect = chai.expect;
20+
21+
describe("RelationshipUpdater", async () => {
22+
let container: Container;
23+
let userDB: UserDB;
24+
let orgDB: TeamDB;
25+
let migrator: RelationshipUpdater;
26+
let authorizer: Authorizer;
27+
28+
beforeEach(async () => {
29+
container = createTestContainer();
30+
Experiments.configureTestingClient({
31+
centralizedPermissions: true,
32+
});
33+
BUILTIN_INSTLLATION_ADMIN_USER_ID;
34+
userDB = container.get<UserDB>(UserDB);
35+
orgDB = container.get<TeamDB>(TeamDB);
36+
migrator = container.get<RelationshipUpdater>(RelationshipUpdater);
37+
authorizer = container.get<Authorizer>(Authorizer);
38+
});
39+
40+
afterEach(async () => {
41+
// Clean-up database
42+
await resetDB(container.get(TypeORM));
43+
});
44+
45+
it("should update a simple user", async () => {
46+
let user = await userDB.newUser();
47+
user = await migrate(user);
48+
49+
await expectRs(user, "container", installation);
50+
await expectRs(user, "self", user);
51+
await expectRs(installation, "member", user);
52+
});
53+
54+
it("should update a simple user organization owned", async () => {
55+
let user = await userDB.newUser();
56+
const org = await orgDB.createTeam(user.id, "MyOrg");
57+
user.organizationId = org.id;
58+
user = await userDB.storeUser(user);
59+
60+
user = await migrate(user);
61+
62+
await expectRs(user, "container", org);
63+
await expectRs(user, "self", user);
64+
await expectRs(org, "installation", installation);
65+
await expectRs(org, "member", user);
66+
await expectRs(org, "owner", user);
67+
});
68+
69+
async function expectRs(res: Resource, relation: Relation, target: Resource): Promise<void> {
70+
const expected = new Relationship(
71+
Resource.getType(res),
72+
res.id,
73+
relation,
74+
Resource.getType(target),
75+
target.id,
76+
).toString();
77+
const rs = await authorizer.readRelationships(res, relation, target);
78+
const all = await authorizer.readRelationships(res, relation);
79+
expect(
80+
rs.length,
81+
`Expected ${expected} but got ${JSON.stringify(rs)} (all rs of this kind ${JSON.stringify(all)})`,
82+
).to.equal(1);
83+
expect(rs[0].toString()).to.equal(expected);
84+
}
85+
86+
async function migrate(user: User): Promise<User> {
87+
user = await migrator.migrate(user);
88+
expect(user.additionalData?.fgaRelationshipsVersion).to.equal(migrator.version);
89+
return user;
90+
}
91+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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 { ProjectDB, TeamDB, UserDB } from "@gitpod/gitpod-db/lib";
8+
import { AdditionalUserData, Organization, User } from "@gitpod/gitpod-protocol";
9+
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
10+
import { inject, injectable } from "inversify";
11+
import { RedisMutex } from "../redis/mutex";
12+
import { Authorizer } from "./authorizer";
13+
14+
@injectable()
15+
export class RelationshipUpdater {
16+
public readonly version = 1;
17+
18+
constructor(
19+
@inject(RedisMutex) private readonly mutex: RedisMutex,
20+
@inject(UserDB) private readonly userDB: UserDB,
21+
@inject(TeamDB) private readonly orgDB: TeamDB,
22+
@inject(ProjectDB) private readonly projectDB: ProjectDB,
23+
@inject(Authorizer) private readonly authorizer: Authorizer,
24+
) {}
25+
26+
/**
27+
* Updates all relationships for a user according to the current state of the database.
28+
* @param user
29+
* @returns
30+
*/
31+
public async migrate(user: User): Promise<User> {
32+
if (user?.additionalData?.fgaRelationshipsVersion === this.version) {
33+
return user;
34+
}
35+
return this.mutex.using([`fga-migration-${user.id}`], 2000, async () => {
36+
const now = new Date().getTime();
37+
try {
38+
log.info({ userId: user.id }, `Updating FGA relationships for user.`, {
39+
fromVersion: user?.additionalData?.fgaRelationshipsVersion,
40+
toVersion: this.version,
41+
});
42+
await this.updateUser(user);
43+
const orgs = await this.orgDB.findTeamsByUser(user.id);
44+
for (const org of orgs) {
45+
await this.updateOrganization(org);
46+
}
47+
return user;
48+
} finally {
49+
AdditionalUserData.set(user, {
50+
fgaRelationshipsVersion: this.version,
51+
});
52+
await this.userDB.updateUserPartial(user);
53+
log.info({ userId: user.id }, `Finished updating relationships.`, {
54+
duration: now - new Date().getTime(),
55+
});
56+
}
57+
});
58+
}
59+
60+
private async updateUser(user: User): Promise<void> {
61+
//TODO remove user first
62+
await this.authorizer.addUser(user.id, user.organizationId);
63+
if (!user.organizationId) {
64+
await this.authorizer.addInstallationMemberRole(user.id);
65+
}
66+
}
67+
68+
private async updateOrganization(org: Organization): Promise<void> {
69+
//TODO remove org first
70+
const members = await this.orgDB.findMembersByTeam(org.id);
71+
const projects = await this.projectDB.findProjects(org.id);
72+
await this.authorizer.addOrganization(org, members, projects);
73+
}
74+
}

components/server/src/authorization/spicedb-authorizer.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,11 @@ export class SpiceDBAuthorizer {
9797
observeSpicedbClientLatency("delete", error, timer());
9898
}
9999
}
100+
101+
async readRelationships(req: v1.ReadRelationshipsRequest): Promise<v1.ReadRelationshipsResponse[]> {
102+
if (!this.client) {
103+
return [];
104+
}
105+
return this.client.readRelationships(req);
106+
}
100107
}

components/server/src/container-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ import { RedisSubscriber } from "./messaging/redis-subscriber";
128128
import { Redis } from "ioredis";
129129
import { RedisPublisher, newRedisClient } from "@gitpod/gitpod-db/lib";
130130
import { UserService } from "./user/user-service";
131+
import { RelationshipUpdater as RelationshipUpdater } from "./authorization/relationship-updater";
131132

132133
export const productionContainerModule = new ContainerModule(
133134
(bind, unbind, isBound, rebind, unbindAsync, onActivation, onDeactivation) => {
@@ -311,6 +312,7 @@ export const productionContainerModule = new ContainerModule(
311312
return createInitializingAuthorizer(authorizer);
312313
})
313314
.inSingletonScope();
315+
bind(RelationshipUpdater).toSelf().inSingletonScope();
314316

315317
// grpc / Connect API
316318
bind(APIUserService).toSelf().inSingletonScope();

components/server/src/projects/projects-service.spec.db.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,17 @@
55
*/
66

77
import { TypeORM, UserDB } from "@gitpod/gitpod-db/lib";
8+
import { resetDB } from "@gitpod/gitpod-db/lib/test/reset-db";
89
import { Organization, User } from "@gitpod/gitpod-protocol";
910
import { Experiments } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server";
11+
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
1012
import * as chai from "chai";
1113
import { Container } from "inversify";
1214
import "mocha";
1315
import { OrganizationService } from "../orgs/organization-service";
16+
import { expectError } from "../test/expect-utils";
1417
import { createTestContainer } from "../test/service-testing-container-module";
1518
import { ProjectsService } from "./projects-service";
16-
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
17-
import { resetDB } from "@gitpod/gitpod-db/lib/test/reset-db";
18-
import { expectError } from "../test/expect-utils";
1919

2020
const expect = chai.expect;
2121

0 commit comments

Comments
 (0)