diff --git a/Context.ts b/Context.ts new file mode 100644 index 0000000..72a1c7b --- /dev/null +++ b/Context.ts @@ -0,0 +1,52 @@ +import { + IHttp, + IModify, + IPersistence, + IRead, +} from "@rocket.chat/apps-engine/definition/accessors"; +import { + IApiEndpointInfo, + IApiRequest, +} from "@rocket.chat/apps-engine/definition/api"; +import { EmptyRequestError } from "./errors/EmptyRequestError"; +import { RcSdk } from "./rc-sdk/RcSdk"; + +export class Context { + public readonly rc: RcSdk; + public readonly request: IApiRequest; + public readonly endpoint: IApiEndpointInfo; + public readonly read: IRead; + public readonly modify: IModify; + public readonly http: IHttp; + public readonly persis: IPersistence; + constructor( + request: IApiRequest, + endpoint: IApiEndpointInfo, + read: IRead, + modify: IModify, + http: IHttp, + persis: IPersistence, + ) { + this.rc = new RcSdk(http, read); + this.request = request; + this.endpoint = endpoint; + this.read = read; + this.modify = modify; + this.http = http; + this.persis = persis; + } + + public id(): string { + return this.request.params.id; + } + + public content(): any { + if ( + !this.request.content || + Object.keys(this.request.content).length === 0 + ) { + throw new EmptyRequestError(); + } + return this.request.content; + } +} diff --git a/GroupEndpoint.ts b/GroupEndpoint.ts new file mode 100644 index 0000000..42fe86c --- /dev/null +++ b/GroupEndpoint.ts @@ -0,0 +1,81 @@ +import { HttpStatusCode } from "@rocket.chat/apps-engine/definition/accessors"; +import { IApiResponse } from "@rocket.chat/apps-engine/definition/api"; +import { Context } from "./Context"; +import { SCIMGroup } from "./scim/Group"; +import { SCIMUser } from "./scim/User"; +import { IScimEndpoint, ScimEndpoint } from "./ScimEndpoint"; + +export class GroupEndpoint extends ScimEndpoint implements IScimEndpoint { + public path = "Groups/:id"; + + public async _get(ctx: Context): Promise { + const getTeamInfo = async () => { + const teamRaw = await ctx.rc.team.info(ctx.id()); + this.handleError(teamRaw); + return teamRaw.teamInfo; + }; + + const getTeamMembers = async () => { + const membersRaw = await ctx.rc.team.members(ctx.id()); + this.handleError(membersRaw); + return membersRaw.members; + }; + + const teamArgs = await Promise.all([getTeamInfo(), getTeamMembers()]); + const group = SCIMGroup.fromRC(...teamArgs); + return this.success(group); + } + + public async _put(ctx: Context): Promise { + const membersRaw = await ctx.rc.team.members(ctx.id()); + this.handleError(membersRaw); + const targetIds = new Set( + SCIMGroup.fromPlain(ctx.content()).members.map((x) => x.value), + ); + const currentIds = new Set( + membersRaw.members.map((x: ITeamMember) => x.user._id), + ); + const removeMember = async (userId: string) => { + const removeMembersRaw = await ctx.rc.team.removeMember({ + userId, + teamId: ctx.id(), + }); + this.handleError(removeMembersRaw); + }; + const addMembers = async (userIds: Array) => { + const addMembersRaw = await ctx.rc.team.addMembers({ + teamId: ctx.id(), + members: userIds.map((userId) => ({ + userId, + roles: ["member"], + })), + }); + this.handleError(addMembersRaw); + }; + const promises: Array> = []; + for (const currentId of currentIds) { + if (!targetIds.has(currentId)) { + promises.push(removeMember(currentId)); + } + } + const addMemberIds: Array = []; + for (const targetId of targetIds) { + if (!currentIds.has(targetId)) { + addMemberIds.push(targetId); + } + } + if (addMemberIds.length > 0) { + promises.push(addMembers(addMemberIds)); + } + await Promise.all(promises); + return this._get(ctx); + } + + public async _delete(ctx: Context): Promise { + const o = await ctx.rc.team.delete({ teamId: ctx.id() }); + this.handleError(o); + return this.response({ + status: HttpStatusCode.NO_CONTENT, + }); + } +} diff --git a/GroupsEndpoint.ts b/GroupsEndpoint.ts new file mode 100644 index 0000000..e226057 --- /dev/null +++ b/GroupsEndpoint.ts @@ -0,0 +1,53 @@ +import { + HttpStatusCode, + IHttp, + IModify, + IPersistence, + IRead, +} from "@rocket.chat/apps-engine/definition/accessors"; +import { + IApiEndpointInfo, + IApiRequest, + IApiResponse, +} from "@rocket.chat/apps-engine/definition/api"; +import crypto = require("crypto"); +import { Context } from "./Context"; +import { RcHttp } from "./RcHttp"; +import { SCIMGroup } from "./scim/Group"; +import { SCIMListResponse } from "./scim/ListResponse"; +import { SCIMUser } from "./scim/User"; +import { IScimEndpoint, ScimEndpoint } from "./ScimEndpoint"; + +export class GroupsEndpoint extends ScimEndpoint implements IScimEndpoint { + public path = "Groups"; + + public async _get(ctx: Context): Promise { + const teamsRaw = await ctx.rc.team.listAll(); + this.handleError(teamsRaw); + const groups = teamsRaw.teams.map(async (team: ITeam) => { + const membersRaw = await ctx.rc.team.members(team._id); + this.handleError(membersRaw); + return SCIMGroup.fromRC(team, membersRaw.members); + }); + const list = new SCIMListResponse(); + list.Resources = await Promise.all(groups); + list.totalResults = teamsRaw.total; + return this.success(list); + } + + public async _post(ctx: Context): Promise { + const u = SCIMGroup.fromPlain(ctx.content()); + const o = await ctx.rc.team.create({ + name: u.displayName, + type: 0, + members: u.members.map((x) => x.value), + }); + this.handleError(o); + const m = await ctx.rc.team.members(o.team._id); + const group = SCIMGroup.fromRC(o.team, m.members); + return this.response({ + status: HttpStatusCode.CREATED, + content: group, + }); + } +} diff --git a/RcHttp.ts b/RcHttp.ts index df96a02..27df6e4 100644 --- a/RcHttp.ts +++ b/RcHttp.ts @@ -18,35 +18,35 @@ export class RcHttp implements IHttp { public async get(url: string, content?: any): Promise { return this.http.get( this.buildUrl(url), - await this.buildOptions(content) + await this.buildOptions(content), ); } public async post(url: string, content?: any): Promise { return this.http.post( this.buildUrl(url), - await this.buildOptions(content) + await this.buildOptions(content), ); } public async put(url: string, content?: any): Promise { return this.http.put( this.buildUrl(url), - await this.buildOptions(content) + await this.buildOptions(content), ); } public async del(url: string, content?: any): Promise { return this.http.del( this.buildUrl(url), - await this.buildOptions(content) + await this.buildOptions(content), ); } public async patch(url: string, content?: any): Promise { return this.http.patch( this.buildUrl(url), - await this.buildOptions(content) + await this.buildOptions(content), ); } diff --git a/ScimApp.ts b/ScimApp.ts index 3ab3e2f..b863074 100644 --- a/ScimApp.ts +++ b/ScimApp.ts @@ -10,6 +10,8 @@ import { import { App } from "@rocket.chat/apps-engine/definition/App"; import { IAppInfo } from "@rocket.chat/apps-engine/definition/metadata"; import { SettingType } from "@rocket.chat/apps-engine/definition/settings"; +import { GroupEndpoint } from "./GroupEndpoint"; +import { GroupsEndpoint } from "./GroupsEndpoint"; import { UserEndpoint } from "./UserEndpoint"; import { UsersEndpoint } from "./UsersEndpoint"; @@ -23,7 +25,12 @@ export class ScimApp extends App { configuration.api.provideApi({ visibility: ApiVisibility.PUBLIC, security: ApiSecurity.UNSECURE, - endpoints: [new UsersEndpoint(this), new UserEndpoint(this)], + endpoints: [ + new UsersEndpoint(this), + new UserEndpoint(this), + new GroupsEndpoint(this), + new GroupEndpoint(this), + ], }); configuration.settings.provideSetting({ diff --git a/ScimEndpoint.ts b/ScimEndpoint.ts index fd32322..5e72d59 100644 --- a/ScimEndpoint.ts +++ b/ScimEndpoint.ts @@ -13,6 +13,7 @@ import { IApiResponse, } from "@rocket.chat/apps-engine/definition/api"; import { IApp } from "@rocket.chat/apps-engine/definition/IApp"; +import { Context } from "./Context"; import { ConflictError } from "./errors/ConflictError"; import { EmptyRequestError } from "./errors/EmptyRequestError"; import { EmptyResponseError } from "./errors/EmptyResponseError"; @@ -29,38 +30,10 @@ type ApiEndpointMethod = ( ) => Promise; export interface IScimEndpoint { - _get?( - request: IApiRequest, - endpoint: IApiEndpointInfo, - read: IRead, - modify: IModify, - http: IHttp, - persis: IPersistence, - ): Promise; - _post?( - request: IApiRequest, - endpoint: IApiEndpointInfo, - read: IRead, - modify: IModify, - http: IHttp, - persis: IPersistence, - ): Promise; - _put?( - request: IApiRequest, - endpoint: IApiEndpointInfo, - read: IRead, - modify: IModify, - http: IHttp, - persis: IPersistence, - ): Promise; - _delete?( - request: IApiRequest, - endpoint: IApiEndpointInfo, - read: IRead, - modify: IModify, - http: IHttp, - persis: IPersistence, - ): Promise; + _get?(ctx: Context): Promise; + _post?(ctx: Context): Promise; + _put?(ctx: Context): Promise; + _delete?(ctx: Context): Promise; } export abstract class ScimEndpoint extends ApiEndpoint { @@ -120,12 +93,12 @@ export abstract class ScimEndpoint extends ApiEndpoint { protected handleError(o: any) { if (!o.success) { - if (o.error.includes("already in use")) { + if (o.error?.includes("already in use")) { throw new ConflictError( o.error.includes("@") ? "email" : "username", ); } - if (o.error.includes("not found")) { + if (o.error?.includes("not found")) { } throw new Error(o.error); } @@ -146,12 +119,7 @@ export abstract class ScimEndpoint extends ApiEndpoint { ): Promise => { try { return await method.bind(this)( - request, - endpoint, - read, - modify, - http, - persis, + new Context(request, endpoint, read, modify, http, persis), ); } catch (e) { let err: SCIMError; diff --git a/UserEndpoint.ts b/UserEndpoint.ts index 2d8f154..18fab64 100644 --- a/UserEndpoint.ts +++ b/UserEndpoint.ts @@ -1,75 +1,33 @@ -import { - HttpStatusCode, - IHttp, - IModify, - IPersistence, - IRead, -} from "@rocket.chat/apps-engine/definition/accessors"; -import { - IApiEndpointInfo, - IApiRequest, - IApiResponse, -} from "@rocket.chat/apps-engine/definition/api"; -import { RcHttp } from "./RcHttp"; +import { HttpStatusCode } from "@rocket.chat/apps-engine/definition/accessors"; +import { IApiResponse } from "@rocket.chat/apps-engine/definition/api"; +import { Context } from "./Context"; import { SCIMUser } from "./scim/User"; import { IScimEndpoint, ScimEndpoint } from "./ScimEndpoint"; export class UserEndpoint extends ScimEndpoint implements IScimEndpoint { public path = "Users/:id"; - public async _get( - request: IApiRequest, - endpoint: IApiEndpointInfo, - read: IRead, - modify: IModify, - http: IHttp, - persis: IPersistence, - ): Promise { - const response = await new RcHttp(http, read).get( - `users.info?userId=${request.params.id}`, - ); - const o = this.parseResponse(response); + public async _get(ctx: Context): Promise { + const o = await ctx.rc.user.info(ctx.id()); this.handleError(o); const user = SCIMUser.fromRC(o.user); return this.success(user); } - public async _put( - request: IApiRequest, - endpoint: IApiEndpointInfo, - read: IRead, - modify: IModify, - http: IHttp, - persis: IPersistence, - ): Promise { - this.hasContent(request); - const response = await new RcHttp(http, read).post( - "users.update", - this.scimToUserUpdate( - request.params.id, - SCIMUser.fromPlain(request.content), - ), + public async _put(ctx: Context): Promise { + const o = await ctx.rc.user.update( + this.scimToUserUpdate(ctx.id(), SCIMUser.fromPlain(ctx.content())), ); - const o = this.parseResponse(response); this.handleError(o); const user = SCIMUser.fromRC(o.user); return this.success(user); } - public async _delete( - request: IApiRequest, - endpoint: IApiEndpointInfo, - read: IRead, - modify: IModify, - http: IHttp, - persis: IPersistence, - ): Promise { - const d: IUserDelete = { - userId: request.params.id, + public async _delete(ctx: Context): Promise { + const o = await ctx.rc.user.delete({ + userId: ctx.id(), confirmRelinquish: true, - }; - const response = await new RcHttp(http, read).post("users.delete", d); - const o = this.parseResponse(response); + }); this.handleError(o); return this.response({ status: HttpStatusCode.NO_CONTENT, diff --git a/UsersEndpoint.ts b/UsersEndpoint.ts index 8234a6e..5e21a27 100644 --- a/UsersEndpoint.ts +++ b/UsersEndpoint.ts @@ -1,17 +1,7 @@ -import { - HttpStatusCode, - IHttp, - IModify, - IPersistence, - IRead, -} from "@rocket.chat/apps-engine/definition/accessors"; -import { - IApiEndpointInfo, - IApiRequest, - IApiResponse, -} from "@rocket.chat/apps-engine/definition/api"; +import { HttpStatusCode } from "@rocket.chat/apps-engine/definition/accessors"; +import { IApiResponse } from "@rocket.chat/apps-engine/definition/api"; import crypto = require("crypto"); -import { RcHttp } from "./RcHttp"; +import { Context } from "./Context"; import { SCIMListResponse } from "./scim/ListResponse"; import { SCIMUser } from "./scim/User"; import { IScimEndpoint, ScimEndpoint } from "./ScimEndpoint"; @@ -19,18 +9,8 @@ import { IScimEndpoint, ScimEndpoint } from "./ScimEndpoint"; export class UsersEndpoint extends ScimEndpoint implements IScimEndpoint { public path = "Users"; - public async _get( - request: IApiRequest, - endpoint: IApiEndpointInfo, - read: IRead, - modify: IModify, - http: IHttp, - persis: IPersistence, - ): Promise { - const response = await new RcHttp(http, read).get( - `users.list?query={"type":{"$eq":"user"}}&fields={"createdAt":1}`, - ); - const o = this.parseResponse(response); + public async _get(ctx: Context): Promise { + const o = await ctx.rc.user.list(); this.handleError(o); const list = new SCIMListResponse(); list.Resources = o.users.map(SCIMUser.fromRC); @@ -38,20 +18,10 @@ export class UsersEndpoint extends ScimEndpoint implements IScimEndpoint { return this.success(list); } - public async _post( - request: IApiRequest, - endpoint: IApiEndpointInfo, - read: IRead, - modify: IModify, - http: IHttp, - persis: IPersistence, - ): Promise { - this.hasContent(request); - const response = await new RcHttp(http, read).post( - `users.create`, - this.scimToUserCreate(SCIMUser.fromPlain(request.content)), + public async _post(ctx: Context): Promise { + const o = await ctx.rc.user.create( + this.scimToUserCreate(SCIMUser.fromPlain(ctx.content())), ); - const o = this.parseResponse(response); this.handleError(o); const user = SCIMUser.fromRC(o.user); return this.response({ diff --git a/rc-sdk/RcSdk.ts b/rc-sdk/RcSdk.ts new file mode 100644 index 0000000..96438d1 --- /dev/null +++ b/rc-sdk/RcSdk.ts @@ -0,0 +1,96 @@ +import { + IHttp, + IHttpRequest, + IHttpResponse, + IRead, +} from "@rocket.chat/apps-engine/definition/accessors"; +import { EmptyResponseError } from "../errors/EmptyResponseError"; +import { JsonParseError } from "../errors/JsonParseError"; +import { RcSdkTeam } from "./RcSdkTeam"; +import { RcSdkUser } from "./RcSdkUser"; + +export class RcSdk { + public user: RcSdkUser; + public team: RcSdkTeam; + private readonly baseUrl = "http://localhost:3000/api/v1"; + private readonly http: IHttp; + private readonly read: IRead; + + constructor(http: IHttp, read: IRead) { + this.http = http; + this.read = read; + this.user = new RcSdkUser(this); + this.team = new RcSdkTeam(this); + } + + public async get(url: string, content?: any): Promise { + return this.http.get( + this.buildUrl(url), + await this.buildOptions(content), + ); + } + + public async post(url: string, content?: any): Promise { + return this.http.post( + this.buildUrl(url), + await this.buildOptions(content), + ); + } + + public async put(url: string, content?: any): Promise { + return this.http.put( + this.buildUrl(url), + await this.buildOptions(content), + ); + } + + public async del(url: string, content?: any): Promise { + return this.http.del( + this.buildUrl(url), + await this.buildOptions(content), + ); + } + + public async patch(url: string, content?: any): Promise { + return this.http.patch( + this.buildUrl(url), + await this.buildOptions(content), + ); + } + + public parseResponse(response: IHttpResponse): any { + if (!response.content) { + throw new EmptyResponseError(); + } + let content: any; + try { + content = JSON.parse(response.content); + } catch (e) { + throw new JsonParseError(); + } + return content; + } + private buildUrl(url: string): string { + return `${this.baseUrl}/${url}`; + } + + private async buildOptions(content?: any): Promise { + const options: IHttpRequest = { + headers: { + "X-User-Id": await this.read + .getEnvironmentReader() + .getSettings() + .getValueById("rc-user-id"), + "X-Auth-Token": await this.read + .getEnvironmentReader() + .getSettings() + .getValueById("rc-token"), + "Content-Type": "application/json", + }, + }; + if (content !== undefined) { + options.content = JSON.stringify(content); + } + return options; + } +} diff --git a/rc-sdk/RcSdkTeam.ts b/rc-sdk/RcSdkTeam.ts new file mode 100644 index 0000000..08afe4e --- /dev/null +++ b/rc-sdk/RcSdkTeam.ts @@ -0,0 +1,70 @@ +import { RcSdk } from "./RcSdk"; + +interface ITeamRemoveMemberBody { + teamId: string; + userId: string; +} + +interface ITeamAddMemberBody { + teamId: string; + members: Array<{ + userId: string; + roles: Array; + }>; +} + +interface ITeamDeleteBody { + teamId: string; + roomsToRemove?: Array; +} + +interface ITeamCreateBody { + name: string; + type: 0 | 1; + members?: Array; + room?: { + readOnly: boolean; + }; +} + +export class RcSdkTeam { + private sdk: RcSdk; + constructor(sdk: RcSdk) { + this.sdk = sdk; + } + + public async listAll(): Promise { + const response = await this.sdk.get(`teams.listAll`); + return this.sdk.parseResponse(response); + } + + public async members(teamId: string): Promise { + const response = await this.sdk.get(`teams.members?teamId=${teamId}`); + return this.sdk.parseResponse(response); + } + + public async info(teamId: string): Promise { + const response = await this.sdk.get(`teams.info?teamId=${teamId}`); + return this.sdk.parseResponse(response); + } + + public async delete(body: ITeamDeleteBody): Promise { + const response = await this.sdk.post(`teams.delete`, body); + return this.sdk.parseResponse(response); + } + + public async create(body: ITeamCreateBody): Promise { + const response = await this.sdk.post(`teams.create`, body); + return this.sdk.parseResponse(response); + } + + public async removeMember(body: ITeamRemoveMemberBody): Promise { + const response = await this.sdk.post(`teams.removeMember`, body); + return this.sdk.parseResponse(response); + } + + public async addMembers(body: ITeamAddMemberBody): Promise { + const response = await this.sdk.post(`teams.addMembers`, body); + return this.sdk.parseResponse(response); + } +} diff --git a/rc-sdk/RcSdkUser.ts b/rc-sdk/RcSdkUser.ts new file mode 100644 index 0000000..5bf3f73 --- /dev/null +++ b/rc-sdk/RcSdkUser.ts @@ -0,0 +1,35 @@ +import { RcSdk } from "./RcSdk"; + +export class RcSdkUser { + private sdk: RcSdk; + constructor(sdk: RcSdk) { + this.sdk = sdk; + } + + public async list(): Promise { + const response = await this.sdk.get( + `users.list?query={"type":{"$eq":"user"}}&fields={"createdAt":1}`, + ); + return this.sdk.parseResponse(response); + } + + public async info(userId: string): Promise { + const response = await this.sdk.get(`users.info?userId=${userId}`); + return this.sdk.parseResponse(response); + } + + public async update(body: IUserUpdate): Promise { + const response = await this.sdk.post(`users.update`, body); + return this.sdk.parseResponse(response); + } + + public async delete(body: IUserDelete): Promise { + const response = await this.sdk.post(`users.delete`); + return this.sdk.parseResponse(response); + } + + public async create(body: IUserCreate): Promise { + const response = await this.sdk.post(`users.create`, body); + return this.sdk.parseResponse(response); + } +} diff --git a/rest.ts b/rest.ts index 23de675..7308cb5 100644 --- a/rest.ts +++ b/rest.ts @@ -41,4 +41,22 @@ interface IUser { username: string; active?: boolean; createdAt: string; + _updatedAt?: string; +} + +interface ITeam { + _id: string; + name: string; + active?: boolean; + createdAt: string; + _updatedAt?: string; +} + +interface ITeamMember { + user: { + _id: string; + name: string; + username: string; + }; + createdAt: string; } diff --git a/scim/Group.ts b/scim/Group.ts new file mode 100644 index 0000000..f4cb90f --- /dev/null +++ b/scim/Group.ts @@ -0,0 +1,40 @@ +import { ISCIMGroupMember, ISCIMResource } from "./Interfaces"; +import { SCIMMeta } from "./Meta"; + +export class SCIMGroup implements ISCIMResource { + public static fromPlain(plain: SCIMGroup): SCIMGroup { + const group = new SCIMGroup(); + group.id = plain.id; + group.externalId = plain.externalId; + group.displayName = plain.displayName; + group.members = plain.members || []; + return group; + } + + public static fromRC(rc: ITeam, members: Array): SCIMGroup { + const group = new SCIMGroup(); + group.id = rc._id; + group.externalId = rc._id; + group.displayName = rc.name; + group.meta.created = new Date(rc.createdAt); + group.meta.lastModified = new Date(rc._updatedAt || rc.createdAt); + group.members = members.map((member) => ({ + value: member.user._id, + $ref: `/Users/${member.user._id}`, + display: member.user.name, + type: "User", + })); + return group; + } + + public readonly schemas = ["urn:ietf:params:scim:schemas:core:2.0:Group"]; + public meta: SCIMMeta; + public id: string; + public externalId: string; + public displayName: string; + public members: Array = []; + + constructor() { + this.meta = new SCIMMeta("Group", () => this.id); + } +} diff --git a/scim/Interfaces.ts b/scim/Interfaces.ts index c9f4108..3259a20 100644 --- a/scim/Interfaces.ts +++ b/scim/Interfaces.ts @@ -26,7 +26,13 @@ export interface ISCIMListResponse { { id: "c75ad752-64ae-4823-840d-ffa80929976c"; userName: "jsmith"; - } + }, ]; } +export interface ISCIMGroupMember { + value: string; + $ref: string; + display: string; + type?: "User" | "Group"; +}