diff --git a/README.md b/README.md index 2a6519f..8baf140 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ This repository contains various examples on how to setup **io.Manager** # Other - [custom-endpoints](./custom-endpoints) - An example that demonstrates how to implement custom endpoints to **io.Manager**. +- [custom-groups-service](./custom-groups-service) - An example that demonstrates how to provide a custom Groups service to **io.Manager**. - [node-esm](./node-esm) - An example that demonstrates how to setup a **io.Manager** in a Node.js native ESM project. - [node-commonjs](./node-commonjs) - An example that demonstrates how to setup a **io.Manager** in a Node.js native CommonJS project. - [custom-logging-config](./custom-logging-config) - An example that demonstrates how to pass custom logging configuration to **io.Manager**. diff --git a/custom-groups-service/.gitignore b/custom-groups-service/.gitignore new file mode 100644 index 0000000..633c49a --- /dev/null +++ b/custom-groups-service/.gitignore @@ -0,0 +1,4 @@ +**/node_modules + +**/dist +**/logs diff --git a/custom-groups-service/README.md b/custom-groups-service/README.md new file mode 100644 index 0000000..c02ff8a --- /dev/null +++ b/custom-groups-service/README.md @@ -0,0 +1,43 @@ +# Introduction + +An example that demonstrates how to provide a custom Groups service to **io.Manager**. + +# Custom Groups service + +By default **io.Manager** stores groups and resolves the groups a user belongs to using its internal Groups Service implementation. Providing a custom Groups service through the `groups_service` configuration property replaces that with your own implementation. + +A custom Groups service implements the `GroupsService` interface exported from `@interopio/manager`. Its `getSupportedFeatures()` method declares which operations the implementation supports - **io.Manager** invokes a group management operation only when the corresponding capability flag is `true`, so an implementation that only reads groups can report `false` for the write operations and leave those methods as stubs. + +This example implements [MyGroupsService](./src/MyGroupsService.ts) as a thin adapter over the in-memory stores in [data.ts](./src/data.ts) that stand in for an external system. + +For an example that combines a custom Groups service with a custom authenticator, see the [auth-custom](../auth-custom) example. + +# Prerequisites + +### Database + +io.Manager requires a database to connect to - this example uses MongoDB, but you can use any other of the supported databases. You will need to either have a local instance or setup a remote database to connect to. For more information visit our Documentation page on the subject: https://docs.interop.io/manager/databases/overview/index.html + +### License + +**io.Manager** requires a license key to operate. To acquire a license key, contact us at `sales@interop.io`. + +# How to run + +- Install npm packages + +```sh + +npm install + +npm audit fix + +``` + +- Start the server + +```sh + +npm run start + +``` diff --git a/custom-groups-service/package.json b/custom-groups-service/package.json new file mode 100644 index 0000000..2e3f091 --- /dev/null +++ b/custom-groups-service/package.json @@ -0,0 +1,17 @@ +{ + "name": "custom-groups-service", + "version": "0.0.1", + "private": true, + "type": "commonjs", + "scripts": { + "build": "npm run delete ./dist && tsc", + "delete": "node -e \"const fs = require('fs'); const path = process.argv[1]; if (!path) process.exit(1); if (fs.existsSync(path)) fs.rmSync(path, { recursive: true, force: true });\"", + "start": "npm run build && node ./dist/index.js" + }, + "dependencies": { + "@interopio/manager": "^4.0.0" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/custom-groups-service/src/MyGroupsService.ts b/custom-groups-service/src/MyGroupsService.ts new file mode 100644 index 0000000..12ead77 --- /dev/null +++ b/custom-groups-service/src/MyGroupsService.ts @@ -0,0 +1,90 @@ +import type { + AuditBuilder, + DataRequest, + Group, + GroupDataResult, + GroupsFeatures, + GroupsService, + User, +} from '@interopio/manager'; + +import { groups, users } from './data.js'; + +export class MyGroupsService implements GroupsService { + // Declares which operations this implementation supports. + public getSupportedFeatures(): GroupsFeatures { + return { + canGetUserGroups: true, + canGetAllGroups: true, + canGetGroup: true, + canAddGroup: true, + canUpdateGroup: true, + canAddOrUpdateGroup: true, + canRemoveGroup: true, + canAddUserToGroup: true, + canRemoveUserFromGroup: true, + }; + } + + // ⚠️ io.Manager authorizes each request against the groups returned here - apart from the + // admin rights granted through `auth_exclusive_users`, this decides what a user can access. + public async getUserGroups(user: string | User): Promise { + const id = typeof user === 'string' ? user : user.id; + + return users.getGroups(id); + } + + public async getAllGroups(_request?: DataRequest): Promise { + const items = groups.getAll(); + + return { + items, + total: items.length, + }; + } + + public async getGroup(name: string): Promise { + return groups.get(name); + } + + public async addGroup(group: Group, _audit: AuditBuilder): Promise { + return groups.add(group); + } + + public async updateGroup(group: Group, _audit: AuditBuilder): Promise { + return groups.update(group); + } + + public async addOrUpdateGroup( + group: Group, + _audit: AuditBuilder + ): Promise { + return groups.addOrUpdate(group); + } + + public async removeGroup(name: string, _audit: AuditBuilder): Promise { + groups.remove(name); + users.removeGroupFromAll(name); + } + + public async addUserToGroups( + user: string, + groups: string[], + _audit: AuditBuilder + ): Promise { + users.addToGroups(user, groups); + } + + public async removeUserFromGroups( + user: string, + groups: string[], + _audit: AuditBuilder + ): Promise { + users.removeFromGroups(user, groups); + } + + public async removeAll(_audit: AuditBuilder): Promise { + groups.clear(); + users.clearGroups(); + } +} diff --git a/custom-groups-service/src/data.ts b/custom-groups-service/src/data.ts new file mode 100644 index 0000000..dce21a4 --- /dev/null +++ b/custom-groups-service/src/data.ts @@ -0,0 +1,142 @@ +import type { Group, User } from '@interopio/manager'; + +export const GROUP_SERVER_ADMIN = 'GLUE42_SERVER_ADMIN'; +export const GROUP_FRONT_OFFICE = 'Front Office'; +export const GROUP_TRADER = 'Trader'; + +export class InMemoryGroupsStore { + private readonly groups: Group[] = [ + { + name: GROUP_FRONT_OFFICE, + }, + { + name: GROUP_TRADER, + }, + ]; + + public getAll(): Group[] { + return [...this.groups]; + } + + public get(name: string): Group | undefined { + return this.groups.find((group) => group.name === name); + } + + public add(group: Group): Group { + if (!this.groups.some((existing) => existing.name === group.name)) { + this.groups.push(group); + } + + return group; + } + + public update(group: Group): Group { + const index = this.groups.findIndex( + (existing) => existing.name === group.name + ); + + if (index !== -1) { + this.groups[index] = group; + } + + return group; + } + + public addOrUpdate(group: Group): Group { + const index = this.groups.findIndex( + (existing) => existing.name === group.name + ); + + if (index !== -1) { + this.groups[index] = group; + } else { + this.groups.push(group); + } + + return group; + } + + public remove(name: string): void { + const index = this.groups.findIndex((group) => group.name === name); + + if (index !== -1) { + this.groups.splice(index, 1); + } + } + + public clear(): void { + this.groups.length = 0; + } +} + +export class InMemoryUsersStore { + private readonly users: User[] = [ + { + id: 'number.one@company.xyz', + email: 'number.one@company.xyz', + apps: [], + groups: [GROUP_FRONT_OFFICE], + }, + { + id: 'number.two@company.xyz', + email: 'number.two@company.xyz', + apps: [], + groups: [GROUP_TRADER], + }, + { + id: 'admin', + email: 'admin@company.xyz', + apps: [], + groups: [GROUP_SERVER_ADMIN], + }, + ]; + + public get(id: string): User | undefined { + return this.users.find((user) => user.id === id); + } + + public getGroups(id: string): string[] { + return [...(this.get(id)?.groups ?? [])]; + } + + public addToGroups(id: string, groups: string[]): void { + const user = this.get(id); + + if (!user) { + return; + } + + for (const group of groups) { + if (!user.groups.includes(group)) { + user.groups.push(group); + } + } + } + + public removeFromGroups(id: string, groups: string[]): void { + const user = this.get(id); + + if (!user) { + return; + } + + user.groups = user.groups.filter((group) => !groups.includes(group)); + } + + // Drops a group from every user that belonged to it. + public removeGroupFromAll(name: string): void { + for (const user of this.users) { + user.groups = user.groups.filter((group) => group !== name); + } + } + + public clearGroups(): void { + for (const user of this.users) { + user.groups = []; + } + } +} + +export const groups = new InMemoryGroupsStore(); + +export const users = new InMemoryUsersStore(); diff --git a/custom-groups-service/src/index.ts b/custom-groups-service/src/index.ts new file mode 100644 index 0000000..438d5bd --- /dev/null +++ b/custom-groups-service/src/index.ts @@ -0,0 +1,27 @@ +import { start, type Config } from '@interopio/manager'; + +import { MyGroupsService } from './MyGroupsService.js'; + +const config: Config = { + name: 'example', + port: 4356, + base: 'api', + // TODO: Contact us at sales@interop.io to acquire a license key. + licenseKey: '', + auth_method: 'none', + auth_exclusive_users: ['admin'], + store: { + type: 'mongo', + // TODO: Replace this with your own MongoDB connection string. + connection: + 'mongodb://db_user:Password123$@localhost:27017/io_manager?authSource=admin&directConnection=true', + }, + token: { + // TODO: Replace this with your secret. + secret: '', + }, + // Replaces the built-in Groups service with the custom implementation. + groups_service: new MyGroupsService(), +}; + +start(config); diff --git a/custom-groups-service/tsconfig.json b/custom-groups-service/tsconfig.json new file mode 100644 index 0000000..fa4ff1f --- /dev/null +++ b/custom-groups-service/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext"], + "module": "node16", + "moduleResolution": "node16", + "skipLibCheck": true, + "sourceMap": true, + "declaration": true, + "rootDir": "./src", + "outDir": "dist", + "pretty": true, + "strict": true + }, + "include": ["src"] +}