diff --git a/src/groups/index.ts b/src/groups/index.ts new file mode 100644 index 00000000..810b2608 --- /dev/null +++ b/src/groups/index.ts @@ -0,0 +1,67 @@ +import ConnectionREST from '../connection/http.js'; +import { Role } from '../roles/types.js'; +import { Map } from '../roles/util.js'; + +import { Role as WeaviateRole } from '../openapi/types.js'; + +export interface Groups { + /** Manage roles of OIDC user groups. */ + oidc: GroupsOIDC; +} + +export interface GroupsOIDC { + /** + * Get the roles assigned to a group specific to the configured OIDC's dynamic auth functionality. + * + * @param {string} groupID The group ID to get the roles for. + * @param {boolean} [includePermissions] Whether to include all associated permissions in the response. + * @returns {Promise>} A map of roles assigned to the group. + */ + getAssignedRoles(groupID: string, includePermissions?: boolean): Promise>; + + /** + * Assign roles to a group specific to the configured OIDC's dynamic auth functionality. + * + * @param {string} groupID The group ID to get the roles for. + * @param {string | string[]} roles The names of the roles to assign to the group. + */ + assignRoles(groupID: string, roles: string | string[]): Promise; + /** + * Revoke roles from a group specific to the configured OIDC's dynamic auth functionality. + * + * @param {string} groupID The group ID to get the roles for. + * @param {string | string[]} roles The names of the roles to revoke from the group. + */ + revokeRoles(groupID: string, roles: string | string[]): Promise; + /** + * Get the known group names specific to the configured OIDC's dynamic auth functionality. + * + * @returns {Promise} A list of known group names. + */ + getKnownGroupNames(): Promise; +} + +export const groups = (connection: ConnectionREST): Groups => ({ + oidc: { + getAssignedRoles: (groupID, includePermissions) => + connection + .get( + `/authz/groups/${encodeURIComponent(groupID)}/roles/oidc${ + includePermissions ? '?includeFullRoles=true' : '' + }` + ) + .then(Map.roles), + assignRoles: (groupID: string, roles: string | string[]): Promise => + connection.postEmpty(`/authz/groups/${encodeURIComponent(groupID)}/assign`, { + roles: Array.isArray(roles) ? roles : [roles], + groupType: 'oidc', + }), + revokeRoles: (groupID: string, roles: string | string[]): Promise => + connection.postEmpty(`/authz/groups/${encodeURIComponent(groupID)}/revoke`, { + roles: Array.isArray(roles) ? roles : [roles], + groupType: 'oidc', + }), + getKnownGroupNames: (): Promise => connection.get(`/authz/groups/oidc`), + }, +}); +export default groups; diff --git a/src/groups/integration.test.ts b/src/groups/integration.test.ts new file mode 100644 index 00000000..1319c425 --- /dev/null +++ b/src/groups/integration.test.ts @@ -0,0 +1,75 @@ +import weaviate, { ApiKey, GroupAssignment } from '..'; +import { requireAtLeast } from '../../test/version.js'; + +requireAtLeast(1, 32, 5).describe('Integration testing of the OIDC groups', () => { + const makeClient = (key: string = 'admin-key') => + weaviate.connectToLocal({ + port: 8091, + grpcPort: 50062, + authCredentials: new ApiKey(key), + }); + + it('should assign / get / revoke group roles', async () => { + const client = await makeClient(); + const groupID = './assign-group'; + const roles = ['viewer', 'admin']; + + await client.groups.oidc.revokeRoles(groupID, roles); + await expect(client.groups.oidc.getAssignedRoles(groupID)).resolves.toEqual({}); + + await client.groups.oidc.assignRoles(groupID, roles); + const assignedRoles = await client.groups.oidc.getAssignedRoles(groupID, true); + expect(Object.keys(assignedRoles)).toEqual(expect.arrayContaining(roles)); + + await client.groups.oidc.revokeRoles(groupID, roles); + await expect(client.groups.oidc.getAssignedRoles(groupID)).resolves.toEqual({}); + }); + + it('should get all known role groups', async () => { + const client = await makeClient(); + const group1 = './group-1'; + const group2 = './group-2'; + + await client.groups.oidc.assignRoles(group1, 'viewer'); + await client.groups.oidc.assignRoles(group2, 'viewer'); + + await expect(client.groups.oidc.getKnownGroupNames()).resolves.toEqual( + expect.arrayContaining([group1, group2]) + ); + + await client.groups.oidc.revokeRoles(group1, 'viewer'); + await client.groups.oidc.revokeRoles(group2, 'viewer'); + + await expect(client.groups.oidc.getKnownGroupNames()).resolves.toHaveLength(0); + }); + + it('should get group assignments', async () => { + const client = await makeClient(); + const roleName = 'test_group_assignements_role'; + await client.roles.delete(roleName).catch((e) => {}); + await client.roles.create(roleName, []).catch((e) => {}); + + await expect(client.roles.getGroupAssignments(roleName)).resolves.toHaveLength(0); + + await client.groups.oidc.assignRoles('./group-1', roleName); + await client.groups.oidc.assignRoles('./group-2', roleName); + await expect(client.roles.getGroupAssignments(roleName)).resolves.toEqual( + expect.arrayContaining([ + { groupID: './group-1', groupType: 'oidc' }, + { groupID: './group-2', groupType: 'oidc' }, + ]) + ); + + await client.groups.oidc.revokeRoles('./group-1', roleName); + await client.groups.oidc.revokeRoles('./group-2', roleName); + await expect(client.roles.getGroupAssignments(roleName)).resolves.toHaveLength(0); + }); + + it('cleanup', async () => { + await makeClient().then((c) => { + c.groups.oidc.revokeRoles('./assign-group', ['viewer', 'admin']).catch((e) => {}); + c.groups.oidc.revokeRoles('./group-1', 'viewer').catch((e) => {}); + c.groups.oidc.revokeRoles('./group-2', 'viewer').catch((e) => {}); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 525ad05d..7c98f79f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,7 @@ import weaviateV2 from './v2/index.js'; import alias, { Aliases } from './alias/index.js'; import filter from './collections/filters/index.js'; import { ConsistencyLevel } from './data/replication.js'; +import groups, { Groups } from './groups/index.js'; import users, { Users } from './users/index.js'; export type ProtocolParams = { @@ -108,6 +109,7 @@ export interface WeaviateClient { cluster: Cluster; collections: Collections; oidcAuth?: OidcAuthenticator; + groups: Groups; roles: Roles; users: Users; @@ -230,6 +232,7 @@ async function client(params: ClientParams): Promise { backup: backup(connection), cluster: cluster(connection), collections: collections(connection, dbVersionSupport), + groups: groups(connection), roles: roles(connection), users: users(connection), close: () => Promise.resolve(connection.close()), // hedge against future changes to add I/O to .close() diff --git a/src/openapi/schema.ts b/src/openapi/schema.ts index 409fbfbe..c2cfab1c 100644 --- a/src/openapi/schema.ts +++ b/src/openapi/schema.ts @@ -319,7 +319,7 @@ export interface definitions { * @description If the group contains OIDC or database users. * @enum {string} */ - GroupType: 'db' | 'oidc'; + GroupType: 'oidc'; /** * @description the type of user * @enum {string} diff --git a/src/openapi/types.ts b/src/openapi/types.ts index ea746b9a..a5805c25 100644 --- a/src/openapi/types.ts +++ b/src/openapi/types.ts @@ -71,6 +71,8 @@ export type Action = definitions['Permission']['action']; export type WeaviateUser = definitions['UserOwnInfo']; export type WeaviateDBUser = definitions['DBUserInfo']; export type WeaviateUserType = definitions['UserTypeOutput']; +export type WeaviateGroupType = definitions['GroupType']; +export type WeaviateGroupAssignment = operations['getGroupsForRole']['responses']['200']['schema'][0]; export type WeaviateUserTypeInternal = definitions['UserTypeInput']; export type WeaviateUserTypeDB = definitions['DBUserInfo']['dbUserType']; export type WeaviateAssignedUser = operations['getUsersForRole']['responses']['200']['schema'][0]; diff --git a/src/roles/index.ts b/src/roles/index.ts index 96a52b57..6760040f 100644 --- a/src/roles/index.ts +++ b/src/roles/index.ts @@ -1,6 +1,7 @@ import { ConnectionREST } from '../index.js'; import { WeaviateAssignedUser, + WeaviateGroupAssignment, Permission as WeaviatePermission, Role as WeaviateRole, } from '../openapi/types.js'; @@ -10,6 +11,9 @@ import { ClusterPermission, CollectionsPermission, DataPermission, + GroupAssignment, + GroupsAction, + GroupsPermission, NodesPermission, Permission, PermissionsInput, @@ -102,6 +106,14 @@ export interface Roles { * @returns {Promise} A promise that resolves to true if the role has the permissions, or false if it does not. */ hasPermissions: (roleName: string, permission: Permission | Permission[]) => Promise; + + /** + * Get the IDs and group type of groups that assigned this role. + * + * @param {string} roleName The name of the role to check. + * @returns {Promise} A promise that resolves to an array of group names assigned to this role. + */ + getGroupAssignments: (roleName: string) => Promise; } const roles = (connection: ConnectionREST): Roles => { @@ -147,6 +159,10 @@ const roles = (connection: ConnectionREST): Roles => { connection.postReturn(`/authz/roles/${roleName}/has-permission`, p) ) ).then((r) => r.every((b) => b)), + getGroupAssignments: (roleName: string) => + connection + .get(`/authz/roles/${roleName}/group-assignments`) + .then(Map.groupsAssignments), }; }; @@ -271,6 +287,49 @@ export const permissions = { return out; }); }, + /** + * This namespace contains methods to create permissions specific to RBAC groups. + */ + groups: { + /** + * Create a set of permissions for 'oidc' groups. + * + * @param {string | string[]} args.groupID IDs of the groups with permissions. + * @param {boolean} [args.read] Whether to allow reading groups. Defaults to `false`. + * @param {boolean} [args.assignAndRevoke] Whether to allow changing group assignements. Defaults to `false`. + * @returns {GroupsPermission[]} The permissions for managing groups. + */ + oidc: (args: { + groupID: string | string[]; + read?: boolean; + assignAndRevoke?: boolean; + }): GroupsPermission[] => { + const groups = Array.isArray(args.groupID) ? args.groupID : [args.groupID]; + const actions: GroupsAction[] = []; + if (args.read) actions.push('read_groups'); + if (args.assignAndRevoke) actions.push('assign_and_revoke_groups'); + return groups.map((gid) => ({ groupID: gid, groupType: 'oidc', actions })); + }, + /** + * Create a set of permissions for 'db' groups. + * + * @param {string | string[]} args.groupID IDs of the groups with permissions. + * @param {boolean} [args.read] Whether to allow reading groups. Defaults to `false`. + * @param {boolean} [args.assignAndRevoke] Whether to allow changing group assignements. Defaults to `false`. + * @returns {GroupsPermission[]} The permissions for managing groups. + */ + // db: (args: { + // groupID: string | string[]; + // read?: boolean; + // assignAndRevoke?: boolean; + // }): GroupsPermission[] => { + // const groups = Array.isArray(args.groupID) ? args.groupID : [args.groupID]; + // const actions: GroupsAction[] = []; + // if (args.read) actions.push('read_groups'); + // if (args.assignAndRevoke) actions.push('assign_and_revoke_groups'); + // return groups.map((gid) => ({ groupID: gid, groupType: 'db', actions })); + // }, + }, /** * This namespace contains methods to create permissions specific to nodes. */ diff --git a/src/roles/integration.test.ts b/src/roles/integration.test.ts index c4d3ea33..ccddcf82 100644 --- a/src/roles/integration.test.ts +++ b/src/roles/integration.test.ts @@ -25,6 +25,7 @@ const emptyPermissions = { clusterPermissions: [], collectionsPermissions: [], dataPermissions: [], + groupsPermissions: [], nodesPermissions: [], rolesPermissions: [], tenantsPermissions: [], @@ -160,6 +161,23 @@ const testCases: TestCase[] = [ ], }, }, + { + roleName: 'groups-oidc', + requireVersion: [1, 33, 0], + permissions: weaviate.permissions.groups.oidc({ + groupID: ['G1', 'G2'], + read: true, + assignAndRevoke: true, + }), + expected: { + name: 'groups-oidc', + ...emptyPermissions, + groupsPermissions: [ + { groupID: 'G1', groupType: 'oidc', actions: ['read_groups', 'assign_and_revoke_groups'] }, + { groupID: 'G2', groupType: 'oidc', actions: ['read_groups', 'assign_and_revoke_groups'] }, + ], + }, + }, { roleName: 'nodes-verbose', permissions: weaviate.permissions.nodes.verbose({ diff --git a/src/roles/types.ts b/src/roles/types.ts index 5b94d8fb..2a757fe3 100644 --- a/src/roles/types.ts +++ b/src/roles/types.ts @@ -1,4 +1,4 @@ -import { Action, WeaviateUserType } from '../openapi/types.js'; +import { Action, WeaviateGroupType, WeaviateUserType } from '../openapi/types.js'; export type AliasAction = Extract< Action, @@ -18,6 +18,7 @@ export type DataAction = Extract< Action, 'create_data' | 'delete_data' | 'read_data' | 'update_data' | 'manage_data' >; +export type GroupsAction = Extract; export type NodesAction = Extract; export type RolesAction = Extract; export type TenantsAction = Extract< @@ -31,6 +32,11 @@ export type UserAssignment = { userType: WeaviateUserType; }; +export type GroupAssignment = { + groupID: string; + groupType: WeaviateGroupType; +}; + export type AliasPermission = { alias: string; collection: string; @@ -57,6 +63,12 @@ export type DataPermission = { actions: DataAction[]; }; +export type GroupsPermission = { + groupID: string; + groupType: WeaviateGroupType; + actions: GroupsAction[]; +}; + export type NodesPermission = { collection: string; verbosity: 'verbose' | 'minimal'; @@ -86,6 +98,7 @@ export type Role = { clusterPermissions: ClusterPermission[]; collectionsPermissions: CollectionsPermission[]; dataPermissions: DataPermission[]; + groupsPermissions: GroupsPermission[]; nodesPermissions: NodesPermission[]; rolesPermissions: RolesPermission[]; tenantsPermissions: TenantsPermission[]; @@ -98,6 +111,7 @@ export type Permission = | ClusterPermission | CollectionsPermission | DataPermission + | GroupsPermission | NodesPermission | RolesPermission | TenantsPermission diff --git a/src/roles/util.ts b/src/roles/util.ts index dd5f4f11..d1607e12 100644 --- a/src/roles/util.ts +++ b/src/roles/util.ts @@ -1,6 +1,7 @@ import { WeaviateAssignedUser, WeaviateDBUser, + WeaviateGroupAssignment, Permission as WeaviatePermission, Role as WeaviateRole, WeaviateUser, @@ -17,6 +18,9 @@ import { CollectionsPermission, DataAction, DataPermission, + GroupAssignment, + GroupsAction, + GroupsPermission, NodesAction, NodesPermission, Permission, @@ -65,6 +69,8 @@ export class PermissionGuards { 'read_data', 'update_data' ); + static isGroups = (permission: Permission): permission is GroupsPermission => + PermissionGuards.includes(permission, 'read_groups', 'assign_and_revoke_groups'); static isNodes = (permission: Permission): permission is NodesPermission => PermissionGuards.includes(permission, 'read_nodes'); static isRoles = (permission: Permission): permission is RolesPermission => @@ -127,6 +133,11 @@ export class Map { data: permission, action, })); + } else if (PermissionGuards.isGroups(permission)) { + return Array.from(permission.actions).map((action) => ({ + groups: { group: permission.groupID, groupType: permission.groupType }, + action, + })); } else if (PermissionGuards.isNodes(permission)) { return Array.from(permission.actions).map((action) => ({ nodes: permission, @@ -157,6 +168,12 @@ export class Map { {} as Record ); + static groupsAssignments = (groups: WeaviateGroupAssignment[]): GroupAssignment[] => + groups.map((g) => ({ + groupID: g.groupId || '', + groupType: g.groupType, + })); + static users = (users: string[]): Record => users.reduce( (acc, user) => ({ @@ -199,6 +216,7 @@ class PermissionsMapping { cluster: {}, collections: {}, data: {}, + groups: {}, nodes: {}, roles: {}, tenants: {}, @@ -222,6 +240,7 @@ class PermissionsMapping { clusterPermissions: Object.values(this.mappings.cluster), collectionsPermissions: Object.values(this.mappings.collections), dataPermissions: Object.values(this.mappings.data), + groupsPermissions: Object.values(this.mappings.groups), nodesPermissions: Object.values(this.mappings.nodes), rolesPermissions: Object.values(this.mappings.roles), tenantsPermissions: Object.values(this.mappings.tenants), @@ -278,6 +297,18 @@ class PermissionsMapping { } }; + private groups = (permission: WeaviatePermission) => { + if (permission.groups !== undefined) { + const { group, groupType } = permission.groups; + if (group === undefined) throw new Error('Group permission missing groupID'); + if (groupType === undefined) throw new Error('Group permission missing groupType'); + const key = `${groupType}#${group}`; + if (this.mappings.groups[key] === undefined) + this.mappings.groups[key] = { groupType, groupID: group, actions: [] }; + this.mappings.groups[key].actions.push(permission.action as GroupsAction); + } + }; + private nodes = (permission: WeaviatePermission) => { if (permission.nodes !== undefined) { let { collection } = permission.nodes; @@ -329,6 +360,7 @@ class PermissionsMapping { this.cluster(permission); this.collections(permission); this.data(permission); + this.groups(permission); this.nodes(permission); this.roles(permission); this.tenants(permission); @@ -342,6 +374,7 @@ type PermissionMappings = { cluster: Record; collections: Record; data: Record; + groups: Record; nodes: Record; roles: Record; tenants: Record; diff --git a/src/users/index.ts b/src/users/index.ts index 3bae45f5..79e33a72 100644 --- a/src/users/index.ts +++ b/src/users/index.ts @@ -150,7 +150,7 @@ const users = (connection: ConnectionREST): Users => { return { getMyUser: () => connection.get('/users/own-info').then(Map.user), getAssignedRoles: (userId: string) => - connection.get(`/authz/users/${userId}/roles`).then(Map.roles), + connection.get(`/authz/users/${encodeURIComponent(userId)}/roles`).then(Map.roles), assignRoles: (roleNames: string | string[], userId: string) => base.assignRoles(roleNames, userId), revokeRoles: (roleNames: string | string[], userId: string) => base.revokeRoles(roleNames, userId), db: db(connection), @@ -182,30 +182,35 @@ const db = (connection: ConnectionREST): DBUsers => { ns.revokeRoles(roleNames, userId, { userType: 'db' }), create: (userId: string) => - connection.postReturn(`/users/db/${userId}`, null).then((resp) => resp.apikey), + connection + .postReturn(`/users/db/${encodeURIComponent(userId)}`, null) + .then((resp) => resp.apikey), delete: (userId: string) => connection - .delete(`/users/db/${userId}`, null) + .delete(`/users/db/${encodeURIComponent(userId)}`, null) .then(() => true) .catch(() => false), rotateKey: (userId: string) => connection - .postReturn(`/users/db/${userId}/rotate-key`, null) + .postReturn(`/users/db/${encodeURIComponent(userId)}/rotate-key`, null) .then((resp) => resp.apikey), activate: (userId: string) => connection - .postEmpty(`/users/db/${userId}/activate`, null) + .postEmpty(`/users/db/${encodeURIComponent(userId)}/activate`, null) .then(() => true) .catch(expectCode(409)), deactivate: (userId: string, opts?: DeactivateOptions) => connection - .postEmpty(`/users/db/${userId}/deactivate`, opts || null) + .postEmpty( + `/users/db/${encodeURIComponent(userId)}/deactivate`, + opts || null + ) .then(() => true) .catch(expectCode(409)), byName: (userId: string, opts?: GetUserOptions) => connection .get( - `/users/db/${userId}?includeLastUsedTime=${opts?.includeLastUsedTime || false}`, + `/users/db/${encodeURIComponent(userId)}?includeLastUsedTime=${opts?.includeLastUsedTime || false}`, true ) .then(Map.dbUser), @@ -254,16 +259,18 @@ const namespacedUsers = (connection: ConnectionREST): NamespacedUsers => { getAssignedRoles: (userType: UserTypeInternal, userId: string, opts?: GetAssignedRolesOptions) => connection .get( - `/authz/users/${userId}/roles/${userType}?includeFullRoles=${opts?.includePermissions || false}` + `/authz/users/${encodeURIComponent(userId)}/roles/${userType}?includeFullRoles=${ + opts?.includePermissions || false + }` ) .then(Map.roles), assignRoles: (roleNames: string | string[], userId: string, opts?: AssignRevokeOptions) => - connection.postEmpty(`/authz/users/${userId}/assign`, { + connection.postEmpty(`/authz/users/${encodeURIComponent(userId)}/assign`, { ...opts, roles: Array.isArray(roleNames) ? roleNames : [roleNames], }), revokeRoles: (roleNames: string | string[], userId: string, opts?: AssignRevokeOptions) => - connection.postEmpty(`/authz/users/${userId}/revoke`, { + connection.postEmpty(`/authz/users/${encodeURIComponent(userId)}/revoke`, { ...opts, roles: Array.isArray(roleNames) ? roleNames : [roleNames], }),