diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 6baf3620..c65e7f40 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -18,7 +18,7 @@ env: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true - + jobs: checks: runs-on: ubuntu-latest @@ -139,4 +139,4 @@ jobs: uses: softprops/action-gh-release@v1 with: generate_release_notes: true - draft: true \ No newline at end of file + draft: true diff --git a/ci/compose.sh b/ci/compose.sh old mode 100644 new mode 100755 diff --git a/ci/docker-compose-rbac.yml b/ci/docker-compose-rbac.yml index 57f2b13a..6091f498 100644 --- a/ci/docker-compose-rbac.yml +++ b/ci/docker-compose-rbac.yml @@ -28,4 +28,10 @@ services: AUTHORIZATION_RBAC_ENABLED: "true" AUTHORIZATION_ADMIN_USERS: "admin-user" AUTHORIZATION_VIEWER_USERS: "viewer-user" + AUTHENTICATION_DB_USERS_ENABLED: "true" + AUTHENTICATION_OIDC_ENABLED: "true" + AUTHENTICATION_OIDC_CLIENT_ID: "wcs" + AUTHENTICATION_OIDC_ISSUER: "https://auth.wcs.api.weaviate.io/auth/realms/SeMI" + AUTHENTICATION_OIDC_USERNAME_CLAIM: "email" + AUTHENTICATION_OIDC_GROUPS_CLAIM: "groups" ... diff --git a/src/collections/backup/unit.test.ts b/src/collections/backup/unit.test.ts index 8cf971a6..f5131d03 100644 --- a/src/collections/backup/unit.test.ts +++ b/src/collections/backup/unit.test.ts @@ -109,8 +109,8 @@ describe('Mock testing of backup cancellation', () => { let mock: CancelMock; beforeAll(async () => { - mock = await CancelMock.use('1.27.0', 8958, 8959); - client = await weaviate.connectToLocal({ port: 8958, grpcPort: 8959 }); + mock = await CancelMock.use('1.27.0', 8912, 8913); + client = await weaviate.connectToLocal({ port: 8912, grpcPort: 8913 }); }); it('should throw while waiting for creation if backup is cancelled in the meantime', async () => { @@ -133,7 +133,7 @@ describe('Mock testing of backup cancellation', () => { }); it('should return false if creation backup does not exist', async () => { - const success = await client.backup.cancel({ backupId: `${BACKUP_ID}4`, backend: BACKEND }); + const success = await client.backup.cancel({ backupId: `${BACKUP_ID}-unknown`, backend: BACKEND }); expect(success).toBe(false); }); diff --git a/src/connection/http.ts b/src/connection/http.ts index 5250d930..1af076fd 100644 --- a/src/connection/http.ts +++ b/src/connection/http.ts @@ -115,11 +115,9 @@ export default class ConnectionREST { postReturn = (path: string, payload: B): Promise => { if (this.authEnabled) { - return this.login().then((token) => - this.http.post(path, payload, true, token).then((res) => res as T) - ); + return this.login().then((token) => this.http.post(path, payload, true, token) as T); } - return this.http.post(path, payload, true, '').then((res) => res as T); + return this.http.post(path, payload, true, '') as Promise; }; postEmpty = (path: string, payload: B): Promise => { diff --git a/src/openapi/schema.ts b/src/openapi/schema.ts index 598ea51b..b5c11aa5 100644 --- a/src/openapi/schema.ts +++ b/src/openapi/schema.ts @@ -40,9 +40,29 @@ export interface paths { }; }; }; + '/replication/replicate': { + post: operations['replicate']; + }; '/users/own-info': { get: operations['getOwnInfo']; }; + '/users/db': { + get: operations['listAllUsers']; + }; + '/users/db/{user_id}': { + get: operations['getUserInfo']; + post: operations['createUser']; + delete: operations['deleteUser']; + }; + '/users/db/{user_id}/rotate-key': { + post: operations['rotateUserApiKey']; + }; + '/users/db/{user_id}/activate': { + post: operations['activateUser']; + }; + '/users/db/{user_id}/deactivate': { + post: operations['deactivateUser']; + }; '/authz/roles': { get: operations['getRoles']; post: operations['createRole']; @@ -61,9 +81,15 @@ export interface paths { post: operations['hasPermission']; }; '/authz/roles/{id}/users': { + get: operations['getUsersForRoleDeprecated']; + }; + '/authz/roles/{id}/user-assignments': { get: operations['getUsersForRole']; }; '/authz/users/{id}/roles': { + get: operations['getRolesForUserDeprecated']; + }; + '/authz/users/{id}/roles/{userType}': { get: operations['getRolesForUser']; }; '/authz/users/{id}/assign': { @@ -231,6 +257,16 @@ export interface paths { } export interface definitions { + /** + * @description the type of user + * @enum {string} + */ + UserTypeInput: 'db' | 'oidc'; + /** + * @description the type of user + * @enum {string} + */ + UserTypeOutput: 'db_user' | 'db_env_user' | 'oidc'; UserOwnInfo: { /** @description The groups associated to the user */ groups?: string[]; @@ -238,6 +274,23 @@ export interface definitions { /** @description The username associated with the provided key */ username: string; }; + DBUserInfo: { + /** @description The role names associated to the user */ + roles: string[]; + /** @description The user id of the given user */ + userId: string; + /** + * @description type of the returned user + * @enum {string} + */ + dbUserType: 'db_user' | 'db_env_user'; + /** @description activity status of the returned user */ + active: boolean; + }; + UserApiKey: { + /** @description The apikey */ + apikey: string; + }; Role: { /** @description role name */ name: string; @@ -349,7 +402,10 @@ export interface definitions { | 'update_collections' | 'delete_collections' | 'assign_and_revoke_users' + | 'create_users' | 'read_users' + | 'update_users' + | 'delete_users' | 'create_tenants' | 'read_tenants' | 'update_tenants' @@ -371,6 +427,7 @@ export interface definitions { /** @description The username that was extracted either from the authentication information */ username?: string; groups?: string[]; + userType?: definitions['UserTypeInput']; }; /** @description An array of available words and contexts. */ C11yWordsResponse: { @@ -603,6 +660,35 @@ export interface definitions { value?: { [key: string]: unknown }; merge?: definitions['Object']; }; + /** @description Request body to add a replica of given shard of a given collection */ + ReplicationReplicateReplicaRequest: { + /** @description The node containing the replica */ + sourceNodeName: string; + /** @description The node to add a copy of the replica on */ + destinationNodeName: string; + /** @description The collection name holding the shard */ + collectionId: string; + /** @description The shard id holding the replica to be copied */ + shardId: string; + }; + /** @description Request body to disable (soft-delete) a replica of given shard of a given collection */ + ReplicationDisableReplicaRequest: { + /** @description The node containing the replica to be disabled */ + nodeName: string; + /** @description The collection name holding the replica to be disabled */ + collectionId: string; + /** @description The shard id holding the replica to be disabled */ + shardId: string; + }; + /** @description Request body to delete a replica of given shard of a given collection */ + ReplicationDeleteReplicaRequest: { + /** @description The node containing the replica to be deleted */ + nodeName: string; + /** @description The collection name holding the replica to be delete */ + collectionId: string; + /** @description The shard id holding the replica to be deleted */ + shardId: string; + }; /** @description A single peer in the network. */ PeerUpdate: { /** @@ -708,7 +794,8 @@ export interface definitions { | 'trigram' | 'gse' | 'kagome_kr' - | 'kagome_ja'; + | 'kagome_ja' + | 'gse_ch'; /** @description The properties of the nested object(s). Applies to object and object[] data types. */ nestedProperties?: definitions['NestedProperty'][]; }; @@ -736,7 +823,8 @@ export interface definitions { | 'trigram' | 'gse' | 'kagome_kr' - | 'kagome_ja'; + | 'kagome_ja' + | 'gse_ch'; /** @description The properties of the nested object(s). Applies to object and object[] data types. */ nestedProperties?: definitions['NestedProperty'][]; }; @@ -1611,6 +1699,35 @@ export interface operations { 503: unknown; }; }; + replicate: { + parameters: { + body: { + body: definitions['ReplicationReplicateReplicaRequest']; + }; + }; + responses: { + /** Replication operation registered successfully */ + 200: unknown; + /** Malformed request. */ + 400: { + schema: definitions['ErrorResponse']; + }; + /** Unauthorized or invalid credentials. */ + 401: unknown; + /** Forbidden */ + 403: { + schema: definitions['ErrorResponse']; + }; + /** Request body is well-formed (i.e., syntactically correct), but semantically erroneous. */ + 422: { + schema: definitions['ErrorResponse']; + }; + /** An error has occurred while trying to fulfill the request. Most likely the ErrorResponse will contain more information about the error. */ + 500: { + schema: definitions['ErrorResponse']; + }; + }; + }; getOwnInfo: { responses: { /** Info about the user */ @@ -1625,6 +1742,229 @@ export interface operations { }; }; }; + listAllUsers: { + responses: { + /** Info about the user */ + 200: { + schema: definitions['DBUserInfo'][]; + }; + /** Unauthorized or invalid credentials. */ + 401: unknown; + /** Forbidden */ + 403: { + schema: definitions['ErrorResponse']; + }; + /** An error has occurred while trying to fulfill the request. Most likely the ErrorResponse will contain more information about the error. */ + 500: { + schema: definitions['ErrorResponse']; + }; + }; + }; + getUserInfo: { + parameters: { + path: { + /** user id */ + user_id: string; + }; + }; + responses: { + /** Info about the user */ + 200: { + schema: definitions['DBUserInfo']; + }; + /** Unauthorized or invalid credentials. */ + 401: unknown; + /** Forbidden */ + 403: { + schema: definitions['ErrorResponse']; + }; + /** user not found */ + 404: unknown; + /** An error has occurred while trying to fulfill the request. Most likely the ErrorResponse will contain more information about the error. */ + 500: { + schema: definitions['ErrorResponse']; + }; + }; + }; + createUser: { + parameters: { + path: { + /** user id */ + user_id: string; + }; + }; + responses: { + /** User created successfully */ + 201: { + schema: definitions['UserApiKey']; + }; + /** Malformed request. */ + 400: { + schema: definitions['ErrorResponse']; + }; + /** Unauthorized or invalid credentials. */ + 401: unknown; + /** Forbidden */ + 403: { + schema: definitions['ErrorResponse']; + }; + /** User already exists */ + 409: { + schema: definitions['ErrorResponse']; + }; + /** Request body is well-formed (i.e., syntactically correct), but semantically erroneous. */ + 422: { + schema: definitions['ErrorResponse']; + }; + /** An error has occurred while trying to fulfill the request. Most likely the ErrorResponse will contain more information about the error. */ + 500: { + schema: definitions['ErrorResponse']; + }; + }; + }; + deleteUser: { + parameters: { + path: { + /** user name */ + user_id: string; + }; + }; + responses: { + /** Successfully deleted. */ + 204: never; + /** Malformed request. */ + 400: { + schema: definitions['ErrorResponse']; + }; + /** Unauthorized or invalid credentials. */ + 401: unknown; + /** Forbidden */ + 403: { + schema: definitions['ErrorResponse']; + }; + /** user not found */ + 404: unknown; + /** Request body is well-formed (i.e., syntactically correct), but semantically erroneous. */ + 422: { + schema: definitions['ErrorResponse']; + }; + /** An error has occurred while trying to fulfill the request. Most likely the ErrorResponse will contain more information about the error. */ + 500: { + schema: definitions['ErrorResponse']; + }; + }; + }; + rotateUserApiKey: { + parameters: { + path: { + /** user id */ + user_id: string; + }; + }; + responses: { + /** ApiKey successfully changed */ + 200: { + schema: definitions['UserApiKey']; + }; + /** Malformed request. */ + 400: { + schema: definitions['ErrorResponse']; + }; + /** Unauthorized or invalid credentials. */ + 401: unknown; + /** Forbidden */ + 403: { + schema: definitions['ErrorResponse']; + }; + /** user not found */ + 404: unknown; + /** Request body is well-formed (i.e., syntactically correct), but semantically erroneous. */ + 422: { + schema: definitions['ErrorResponse']; + }; + /** An error has occurred while trying to fulfill the request. Most likely the ErrorResponse will contain more information about the error. */ + 500: { + schema: definitions['ErrorResponse']; + }; + }; + }; + activateUser: { + parameters: { + path: { + /** user id */ + user_id: string; + }; + }; + responses: { + /** User successfully activated */ + 200: unknown; + /** Malformed request. */ + 400: { + schema: definitions['ErrorResponse']; + }; + /** Unauthorized or invalid credentials. */ + 401: unknown; + /** Forbidden */ + 403: { + schema: definitions['ErrorResponse']; + }; + /** user not found */ + 404: unknown; + /** user already activated */ + 409: unknown; + /** Request body is well-formed (i.e., syntactically correct), but semantically erroneous. */ + 422: { + schema: definitions['ErrorResponse']; + }; + /** An error has occurred while trying to fulfill the request. Most likely the ErrorResponse will contain more information about the error. */ + 500: { + schema: definitions['ErrorResponse']; + }; + }; + }; + deactivateUser: { + parameters: { + path: { + /** user id */ + user_id: string; + }; + body: { + body?: { + /** + * @description if the key should be revoked when deactivating the user + * @default false + */ + revoke_key?: boolean; + }; + }; + }; + responses: { + /** users successfully deactivated */ + 200: unknown; + /** Malformed request. */ + 400: { + schema: definitions['ErrorResponse']; + }; + /** Unauthorized or invalid credentials. */ + 401: unknown; + /** Forbidden */ + 403: { + schema: definitions['ErrorResponse']; + }; + /** user not found */ + 404: unknown; + /** user already deactivated */ + 409: unknown; + /** Request body is well-formed (i.e., syntactically correct), but semantically erroneous. Are you sure the class is defined in the configuration file? */ + 422: { + schema: definitions['ErrorResponse']; + }; + /** An error has occurred while trying to fulfill the request. Most likely the ErrorResponse will contain more information about the error. */ + 500: { + schema: definitions['ErrorResponse']; + }; + }; + }; getRoles: { responses: { /** Successful response. */ @@ -1849,7 +2189,7 @@ export interface operations { }; }; }; - getUsersForRole: { + getUsersForRoleDeprecated: { parameters: { path: { /** role name */ @@ -1879,11 +2219,86 @@ export interface operations { }; }; }; + getUsersForRole: { + parameters: { + path: { + /** role name */ + id: string; + }; + }; + responses: { + /** Users assigned to this role */ + 200: { + schema: ({ + userId?: string; + userType: definitions['UserTypeOutput']; + } & { + name: unknown; + })[]; + }; + /** Bad request */ + 400: { + schema: definitions['ErrorResponse']; + }; + /** Unauthorized or invalid credentials. */ + 401: unknown; + /** Forbidden */ + 403: { + schema: definitions['ErrorResponse']; + }; + /** no role found */ + 404: unknown; + /** An error has occurred while trying to fulfill the request. Most likely the ErrorResponse will contain more information about the error. */ + 500: { + schema: definitions['ErrorResponse']; + }; + }; + }; + getRolesForUserDeprecated: { + parameters: { + path: { + /** user name */ + id: string; + }; + }; + responses: { + /** Role assigned users */ + 200: { + schema: definitions['RolesListResponse']; + }; + /** Bad request */ + 400: { + schema: definitions['ErrorResponse']; + }; + /** Unauthorized or invalid credentials. */ + 401: unknown; + /** Forbidden */ + 403: { + schema: definitions['ErrorResponse']; + }; + /** no role found for user */ + 404: unknown; + /** Request body is well-formed (i.e., syntactically correct), but semantically erroneous. Are you sure the class is defined in the configuration file? */ + 422: { + schema: definitions['ErrorResponse']; + }; + /** An error has occurred while trying to fulfill the request. Most likely the ErrorResponse will contain more information about the error. */ + 500: { + schema: definitions['ErrorResponse']; + }; + }; + }; getRolesForUser: { parameters: { path: { /** user name */ id: string; + /** The type of user */ + userType: 'oidc' | 'db'; + }; + query: { + /** Whether to include detailed role information needed the roles permission */ + includeFullRoles?: boolean; }; }; responses: { @@ -1903,6 +2318,10 @@ export interface operations { }; /** no role found for user */ 404: unknown; + /** Request body is well-formed (i.e., syntactically correct), but semantically erroneous. Are you sure the class is defined in the configuration file? */ + 422: { + schema: definitions['ErrorResponse']; + }; /** An error has occurred while trying to fulfill the request. Most likely the ErrorResponse will contain more information about the error. */ 500: { schema: definitions['ErrorResponse']; @@ -1919,6 +2338,7 @@ export interface operations { body: { /** @description the roles that assigned to user */ roles?: string[]; + userType?: definitions['UserTypeInput']; }; }; }; @@ -1955,6 +2375,7 @@ export interface operations { body: { /** @description the roles that revoked from the key or user */ roles?: string[]; + userType?: definitions['UserTypeInput']; }; }; }; diff --git a/src/openapi/types.ts b/src/openapi/types.ts index 054ab10d..59e6e7d1 100644 --- a/src/openapi/types.ts +++ b/src/openapi/types.ts @@ -1,4 +1,4 @@ -import { definitions } from './schema.js'; +import { definitions, operations } from './schema.js'; type Override = Omit & T2; type DefaultProperties = { [key: string]: unknown }; @@ -54,7 +54,6 @@ export type WeaviateMultiTenancyConfig = WeaviateClass['multiTenancyConfig']; export type WeaviateReplicationConfig = WeaviateClass['replicationConfig']; export type WeaviateShardingConfig = WeaviateClass['shardingConfig']; export type WeaviateShardStatus = definitions['ShardStatusGetResponse']; -export type WeaviateUser = definitions['UserOwnInfo']; export type WeaviateVectorIndexConfig = WeaviateClass['vectorIndexConfig']; export type WeaviateVectorsConfig = WeaviateClass['vectorConfig']; export type WeaviateVectorConfig = definitions['VectorConfig']; @@ -69,3 +68,9 @@ export type Meta = definitions['Meta']; export type Role = definitions['Role']; export type Permission = definitions['Permission']; export type Action = definitions['Permission']['action']; +export type WeaviateUser = definitions['UserOwnInfo']; +export type WeaviateDBUser = definitions['DBUserInfo']; +export type WeaviateUserType = definitions['UserTypeOutput']; +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 e5dd42fa..6971cf4f 100644 --- a/src/roles/index.ts +++ b/src/roles/index.ts @@ -1,5 +1,9 @@ import { ConnectionREST } from '../index.js'; -import { Permission as WeaviatePermission, Role as WeaviateRole } from '../openapi/types.js'; +import { + WeaviateAssignedUser, + Permission as WeaviatePermission, + Role as WeaviateRole, +} from '../openapi/types.js'; import { BackupsPermission, ClusterPermission, @@ -11,6 +15,7 @@ import { Role, RolesPermission, TenantsPermission, + UserAssignment, UsersPermission, } from './types.js'; import { Map } from './util.js'; @@ -30,12 +35,13 @@ export interface Roles { */ byName: (roleName: string) => Promise; /** - * Retrieve the user IDs assigned to a role. + * Retrieve the user IDs assigned to a role. Each user has a qualifying user type, + * e.g. `'db_user' | 'db_env_user' | 'oidc'`. * * @param {string} roleName The name of the role to retrieve the assigned user IDs for. * @returns {Promise} The user IDs assigned to the role. */ - assignedUserIds: (roleName: string) => Promise; + userAssignments: (roleName: string) => Promise; /** * Delete a role by its name. * @@ -89,7 +95,10 @@ const roles = (connection: ConnectionREST): Roles => { listAll: () => connection.get('/authz/roles').then(Map.roles), byName: (roleName: string) => connection.get(`/authz/roles/${roleName}`).then(Map.roleFromWeaviate), - assignedUserIds: (roleName: string) => connection.get(`/authz/roles/${roleName}/users`), + userAssignments: (roleName: string) => + connection + .get(`/authz/roles/${roleName}/user-assignments`, true) + .then(Map.assignedUsers), create: (roleName: string, permissions?: PermissionsInput) => { const perms = permissions ? Map.flattenPermissions(permissions).flatMap(Map.permissionToWeaviate) diff --git a/src/roles/integration.test.ts b/src/roles/integration.test.ts index f90f0465..1d335c9f 100644 --- a/src/roles/integration.test.ts +++ b/src/roles/integration.test.ts @@ -6,10 +6,11 @@ import weaviate, { Role, RolesAction, TenantsAction, + UserAssignment, WeaviateClient, } from '..'; +import { requireAtLeast } from '../../test/version'; import { WeaviateStartUpError, WeaviateUnexpectedStatusCodeError } from '../errors'; -import { DbVersion } from '../utils/dbVersion'; type TestCase = { roleName: string; @@ -278,11 +279,11 @@ const testCases: TestCase[] = [ }, ]; -const maybe = DbVersion.fromString(`v${process.env.WEAVIATE_VERSION!}`).isAtLeast(1, 29, 0) - ? describe - : describe.skip; - -maybe('Integration testing of the roles namespace', () => { +requireAtLeast( + 1, + 29, + 0 +)('Integration testing of the roles namespace', () => { let client: WeaviateClient; beforeAll(async () => { @@ -316,6 +317,36 @@ maybe('Integration testing of the roles namespace', () => { expect(exists).toBeFalsy(); }); + requireAtLeast( + 1, + 30, + 0 + )('namespaced users', () => { + it('retrieves assigned users with namespace', async () => { + await client.roles.create('landlord', { + collection: 'Buildings', + tenant: 'john-doe', + actions: ['create_tenants', 'delete_tenants'], + }); + + await client.users.db.create('Innkeeper').catch((res) => expect(res.code).toEqual(409)); + + await client.users.db.assignRoles('landlord', 'custom-user'); + await client.users.db.assignRoles('landlord', 'Innkeeper'); + + const assignments = await client.roles.userAssignments('landlord'); + expect(assignments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'custom-user', userType: 'db_env_user' }), + expect.objectContaining({ id: 'Innkeeper', userType: 'db_user' }), + ]) + ); + + await client.users.db.delete('Innkeeper'); + await client.roles.delete('landlord'); + }); + }); + describe('should be able to create roles using the permissions factory', () => { testCases.forEach((testCase) => { it(`with ${testCase.roleName} permissions`, async () => { diff --git a/src/roles/types.ts b/src/roles/types.ts index 3a6d0266..93273521 100644 --- a/src/roles/types.ts +++ b/src/roles/types.ts @@ -1,4 +1,4 @@ -import { Action } from '../openapi/types.js'; +import { Action, WeaviateUserType } from '../openapi/types.js'; export type BackupsAction = Extract; export type ClusterAction = Extract; @@ -22,6 +22,11 @@ export type TenantsAction = Extract< >; export type UsersAction = Extract; +export type UserAssignment = { + id: string; + userType: WeaviateUserType; +}; + export type BackupsPermission = { collection: string; actions: BackupsAction[]; diff --git a/src/roles/util.ts b/src/roles/util.ts index bee179db..09f82bb0 100644 --- a/src/roles/util.ts +++ b/src/roles/util.ts @@ -1,5 +1,11 @@ -import { Permission as WeaviatePermission, Role as WeaviateRole, WeaviateUser } from '../openapi/types.js'; -import { User } from '../users/types.js'; +import { + WeaviateAssignedUser, + WeaviateDBUser, + Permission as WeaviatePermission, + Role as WeaviateRole, + WeaviateUser, +} from '../openapi/types.js'; +import { User, UserDB } from '../users/types.js'; import { BackupsAction, BackupsPermission, @@ -18,6 +24,7 @@ import { RolesPermission, TenantsAction, TenantsPermission, + UserAssignment, UsersAction, UsersPermission, } from './types.js'; @@ -123,20 +130,38 @@ export class Map { static roleFromWeaviate = (role: WeaviateRole): Role => PermissionsMapping.use(role).map(); static roles = (roles: WeaviateRole[]): Record => - roles.reduce((acc, role) => { - acc[role.name] = Map.roleFromWeaviate(role); - return acc; - }, {} as Record); + roles.reduce( + (acc, role) => ({ + ...acc, + [role.name]: Map.roleFromWeaviate(role), + }), + {} as Record + ); static users = (users: string[]): Record => - users.reduce((acc, user) => { - acc[user] = { id: user }; - return acc; - }, {} as Record); + users.reduce( + (acc, user) => ({ + ...acc, + [user]: { id: user }, + }), + {} as Record + ); static user = (user: WeaviateUser): User => ({ id: user.username, roles: user.roles?.map(Map.roleFromWeaviate), }); + static dbUser = (user: WeaviateDBUser): UserDB => ({ + userType: user.dbUserType, + id: user.userId, + roleNames: user.roles, + active: user.active, + }); + static dbUsers = (users: WeaviateDBUser[]): UserDB[] => users.map(Map.dbUser); + static assignedUsers = (users: WeaviateAssignedUser[]): UserAssignment[] => + users.map((user) => ({ + id: user.userId || '', + userType: user.userType, + })); } class PermissionsMapping { @@ -160,7 +185,11 @@ class PermissionsMapping { public static use = (role: WeaviateRole) => new PermissionsMapping(role); public map = (): Role => { - this.role.permissions.forEach(this.permissionFromWeaviate); + // If truncated roles are requested (?includeFullRoles=false), + // role.permissions are not present. + if (this.role.permissions !== null) { + this.role.permissions.forEach(this.permissionFromWeaviate); + } return { name: this.role.name, backupsPermissions: Object.values(this.mappings.backups), diff --git a/src/users/index.ts b/src/users/index.ts index 353909fc..7b2c608a 100644 --- a/src/users/index.ts +++ b/src/users/index.ts @@ -1,10 +1,39 @@ +import { WeaviateUnexpectedStatusCodeError } from '../errors.js'; import { ConnectionREST } from '../index.js'; -import { Role as WeaviateRole, WeaviateUser } from '../openapi/types.js'; +import { + WeaviateUserTypeInternal as UserTypeInternal, + WeaviateDBUser, + Role as WeaviateRole, + WeaviateUser, +} from '../openapi/types.js'; import { Role } from '../roles/types.js'; import { Map } from '../roles/util.js'; -import { User } from './types.js'; +import { AssignRevokeOptions, DeactivateOptions, GetAssignedRolesOptions, User, UserDB } from './types.js'; -export interface Users { +/** + * Operations supported for 'db', 'oidc', and legacy (non-namespaced) users. + * Use respective implementations in `users.db` and `users.oidc`, and `users`. + */ +interface UsersBase { + /** + * Assign roles to a user. + * + * @param {string | string[]} roleNames The name or names of the roles to assign. + * @param {string} userId The ID of the user to assign the roles to. + * @returns {Promise} A promise that resolves when the roles are assigned. + */ + assignRoles: (roleNames: string | string[], userId: string) => Promise; + /** + * Revoke roles from a user. + * + * @param {string | string[]} roleNames The name or names of the roles to revoke. + * @param {string} userId The ID of the user to revoke the roles from. + * @returns {Promise} A promise that resolves when the roles are revoked. + */ + revokeRoles: (roleNames: string | string[], userId: string) => Promise; +} + +export interface Users extends UsersBase { /** * Retrieve the information relevant to the currently authenticated user. * @@ -18,35 +47,202 @@ export interface Users { * @returns {Promise>} A map of role names to their respective roles. */ getAssignedRoles: (userId: string) => Promise>; + + db: DBUsers; + oidc: OIDCUsers; +} + +/** Operations supported for namespaced 'db' users.*/ +export interface DBUsers extends UsersBase { /** - * Assign roles to a user. + * Retrieve the roles assigned to a 'db_user' user. * - * @param {string | string[]} roleNames The name or names of the roles to assign. - * @param {string} userId The ID of the user to assign the roles to. - * @returns {Promise} A promise that resolves when the roles are assigned. + * @param {string} userId The ID of the user to retrieve the assigned roles for. + * @returns {Promise>} A map of role names to their respective roles. */ - assignRoles: (roleNames: string | string[], userId: string) => Promise; + getAssignedRoles: (userId: string, opts?: GetAssignedRolesOptions) => Promise>; + + /** Create a new 'db_user' user. + * + * @param {string} userId The ID of the user to create. Must consist of valid URL characters only. + * @returns {Promise} API key for the newly created user. + */ + create: (userId: string) => Promise; + /** - * Revoke roles from a user. + * Delete a 'db_user' user. It is not possible to delete 'db_env_user' users programmatically. * - * @param {string | string[]} roleNames The name or names of the roles to revoke. - * @param {string} userId The ID of the user to revoke the roles from. - * @returns {Promise} A promise that resolves when the roles are revoked. + * @param {string} userId The ID of the user to delete. + * @returns {Promise} `true` if the user has been successfully deleted. */ - revokeRoles: (roleNames: string | string[], userId: string) => Promise; + delete: (userId: string) => Promise; + + /** + * Rotate the API key of a 'db_user' user. The old API key becomes invalid. + * API keys of 'db_env_user' users are defined in the server's environment + * and cannot be modified programmatically. + * + * @param {string} userId The ID of the user to create a new API key for. + * @returns {Promise} New API key for the user. + */ + rotateKey: (userId: string) => Promise; + + /** + * Activate 'db_user' user. + * + * @param {string} userId The ID of the user to activate. + * @returns {Promise} `true` if the user has been successfully activated. + */ + activate: (userId: string) => Promise; + + /** + * Deactivate 'db_user' user. + * + * @param {string} userId The ID of the user to deactivate. + * @returns {Promise} `true` if the user has been successfully deactivated. + */ + deactivate: (userId: string, opts?: DeactivateOptions) => Promise; + + /** + * Retrieve information about the 'db_user' / 'db_env_user' user. + * + * @param {string} userId The ID of the user to get. + * @returns {Promise} ID, status, and assigned roles of a 'db_*' user. + */ + byName: (userId: string) => Promise; + + /** + * List all 'db_user' / 'db_env_user' users. + * + * @returns {Promise} ID, status, and assigned roles for each 'db_*' user. + */ + listAll: () => Promise; +} + +/** Operations supported for namespaced 'oidc' users.*/ +export interface OIDCUsers extends UsersBase { + /** + * Retrieve the roles assigned to an 'oidc' user. + * + * @param {string} userId The ID of the user to retrieve the assigned roles for. + * @returns {Promise>} A map of role names to their respective roles. + */ + getAssignedRoles: (userId: string, opts?: GetAssignedRolesOptions) => Promise>; } const users = (connection: ConnectionREST): Users => { + const base = baseUsers(connection); + return { getMyUser: () => connection.get('/users/own-info').then(Map.user), getAssignedRoles: (userId: string) => connection.get(`/authz/users/${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), + oidc: oidc(connection), + }; +}; + +const db = (connection: ConnectionREST): DBUsers => { + const ns = namespacedUsers(connection); + + /** expectCode returns true if the error contained an expected status code. */ + const expectCode = (code: number): ((_: any) => boolean) => { + return (error) => { + if (error instanceof WeaviateUnexpectedStatusCodeError) { + return error.code === code; + } + throw error; + }; + }; + + type APIKeyResponse = { apikey: string }; + return { + getAssignedRoles: (userId: string, opts?: GetAssignedRolesOptions) => + ns.getAssignedRoles('db', userId, opts), assignRoles: (roleNames: string | string[], userId: string) => + ns.assignRoles(roleNames, userId, { userType: 'db' }), + revokeRoles: (roleNames: string | string[], userId: string) => + ns.revokeRoles(roleNames, userId, { userType: 'db' }), + + create: (userId: string) => + connection.postReturn(`/users/db/${userId}`, null).then((resp) => resp.apikey), + delete: (userId: string) => + connection + .delete(`/users/db/${userId}`, null) + .then(() => true) + .catch(() => false), + rotateKey: (userId: string) => + connection + .postReturn(`/users/db/${userId}/rotate-key`, null) + .then((resp) => resp.apikey), + activate: (userId: string) => + connection + .postEmpty(`/users/db/${userId}/activate`, null) + .then(() => true) + .catch(expectCode(409)), + deactivate: (userId: string, opts?: DeactivateOptions) => + connection + .postEmpty(`/users/db/${userId}/deactivate`, opts || null) + .then(() => true) + .catch(expectCode(409)), + byName: (userId: string) => connection.get(`/users/db/${userId}`, true).then(Map.dbUser), + listAll: () => connection.get('/users/db', true).then(Map.dbUsers), + }; +}; + +const oidc = (connection: ConnectionREST): OIDCUsers => { + const ns = namespacedUsers(connection); + return { + getAssignedRoles: (userId: string, opts?: GetAssignedRolesOptions) => + ns.getAssignedRoles('oidc', userId, opts), + assignRoles: (roleNames: string | string[], userId: string) => + ns.assignRoles(roleNames, userId, { userType: 'oidc' }), + revokeRoles: (roleNames: string | string[], userId: string) => + ns.revokeRoles(roleNames, userId, { userType: 'oidc' }), + }; +}; + +/** Internal interface for operations that MAY accept a 'db'/'oidc' namespace. */ +interface NamespacedUsers { + getAssignedRoles: ( + userType: UserTypeInternal, + userId: string, + opts?: GetAssignedRolesOptions + ) => Promise>; + assignRoles: (roleNames: string | string[], userId: string, opts?: AssignRevokeOptions) => Promise; + revokeRoles: (roleNames: string | string[], userId: string, opts?: AssignRevokeOptions) => Promise; +} + +/** Implementation of the operations common to 'db', 'oidc', and legacy users. */ +const baseUsers = (connection: ConnectionREST): UsersBase => { + const ns = namespacedUsers(connection); + return { + assignRoles: (roleNames: string | string[], userId: string) => ns.assignRoles(roleNames, userId), + revokeRoles: (roleNames: string | string[], userId: string) => ns.revokeRoles(roleNames, userId), + }; +}; + +/** Implementation of the operations common to 'db' and 'oidc' users. */ +const namespacedUsers = (connection: ConnectionREST): NamespacedUsers => { + return { + getAssignedRoles: (userType: UserTypeInternal, userId: string, opts?: GetAssignedRolesOptions) => + connection + .get( + `/authz/users/${userId}/roles/${userType}${ + opts?.includePermissions ? '?&includeFullRoles=true' : '' + }` + ) + .then(Map.roles), + assignRoles: (roleNames: string | string[], userId: string, opts?: AssignRevokeOptions) => connection.postEmpty(`/authz/users/${userId}/assign`, { + ...opts, roles: Array.isArray(roleNames) ? roleNames : [roleNames], }), - revokeRoles: (roleNames: string | string[], userId: string) => + revokeRoles: (roleNames: string | string[], userId: string, opts?: AssignRevokeOptions) => connection.postEmpty(`/authz/users/${userId}/revoke`, { + ...opts, roles: Array.isArray(roleNames) ? roleNames : [roleNames], }), }; diff --git a/src/users/integration.test.ts b/src/users/integration.test.ts index 83d2ec4a..0c442bfe 100644 --- a/src/users/integration.test.ts +++ b/src/users/integration.test.ts @@ -1,11 +1,13 @@ import weaviate, { ApiKey } from '..'; -import { DbVersion } from '../utils/dbVersion'; +import { requireAtLeast } from '../../test/version.js'; +import { WeaviateUserTypeDB } from '../v2'; +import { UserDB } from './types.js'; -const only = DbVersion.fromString(`v${process.env.WEAVIATE_VERSION!}`).isAtLeast(1, 29, 0) - ? describe - : describe.skip; - -only('Integration testing of the users namespace', () => { +requireAtLeast( + 1, + 29, + 0 +)('Integration testing of the users namespace', () => { const makeClient = (key: string) => weaviate.connectToLocal({ port: 8091, @@ -59,5 +61,109 @@ only('Integration testing of the users namespace', () => { expect(roles.test).toBeUndefined(); }); + requireAtLeast( + 1, + 30, + 0 + )('dynamic user management', () => { + it('should be able to manage "db" user lifecycle', async () => { + const client = await makeClient('admin-key'); + + /** Pass false to expect a rejected promise, chain assertions about dynamic-dave otherwise. */ + const expectDave = (ok: boolean = true) => { + const promise = expect(client.users.db.byName('dynamic-dave')); + return ok ? promise.resolves : promise.rejects; + }; + + await client.users.db.create('dynamic-dave'); + await expectDave().toHaveProperty('active', true); + + // Second activation is a no-op + await expect(client.users.db.activate('dynamic-dave')).resolves.toEqual(true); + + await client.users.db.deactivate('dynamic-dave'); + await expectDave().toHaveProperty('active', false); + + // Second deactivation is a no-op + await expect(client.users.db.deactivate('dynamic-dave', { revokeKey: true })).resolves.toEqual(true); + + await client.users.db.delete('dynamic-dave'); + await expectDave(false).toHaveProperty('code', 404); + }); + + it('should be able to obtain and rotate api keys', async () => { + const admin = await makeClient('admin-key'); + const apiKey = await admin.users.db.create('api-ashley'); + + let userAshley = await makeClient(apiKey).then((client) => client.users.getMyUser()); + expect(userAshley.id).toEqual('api-ashley'); + + const newKey = await admin.users.db.rotateKey('api-ashley'); + userAshley = await makeClient(newKey).then((client) => client.users.getMyUser()); + expect(userAshley.id).toEqual('api-ashley'); + }); + + it('should be able to list all dynamic users', async () => { + const admin = await makeClient('admin-key'); + + await Promise.all(['jim', 'pam', 'dwight'].map((user) => admin.users.db.create(user))); + + const all = await admin.users.db.listAll(); + expect(all.length).toBeGreaterThanOrEqual(3); + + const pam = await admin.users.db.byName('pam'); + expect(all).toEqual(expect.arrayContaining([pam])); + }); + + it('should be able to fetch static users', async () => { + const custom = await makeClient('admin-key').then((client) => client.users.db.byName('custom-user')); + expect(custom.userType).toEqual('db_env_user'); + }); + + it.each<'db' | 'oidc'>(['db', 'oidc'])('should be able to assign roles to "%s" users', async (kind) => { + const admin = await makeClient('admin-key'); + + if (kind === 'db') { + await admin.users.db.create('role-rick'); + } + + await admin.users[kind].assignRoles('test', 'role-rick'); + await expect(admin.users[kind].getAssignedRoles('role-rick')).resolves.toEqual( + expect.objectContaining({ test: expect.any(Object) }) + ); + + await admin.users[kind].revokeRoles('test', 'role-rick'); + await expect(admin.users[kind].getAssignedRoles('role-rick')).resolves.toEqual({}); + }); + + it('should be able to fetch assigned roles with all permissions', async () => { + const admin = await makeClient('admin-key'); + + await admin.roles.delete('test'); + await admin.roles.create('test', [ + { collection: 'Things', actions: ['manage_backups'] }, + { collection: 'Things', tenant: 'data-tenant', actions: ['create_data'] }, + { collection: 'Things', verbosity: 'minimal', actions: ['read_nodes'] }, + ]); + await admin.users.db.create('permission-peter'); + await admin.users.db.assignRoles('test', 'permission-peter'); + + const roles = await admin.users.db.getAssignedRoles('permission-peter', { includePermissions: true }); + expect(roles.test.backupsPermissions).toHaveLength(1); + expect(roles.test.dataPermissions).toHaveLength(1); + expect(roles.test.nodesPermissions).toHaveLength(1); + }); + + afterAll(() => + makeClient('admin-key').then(async (c) => { + await Promise.all( + ['jim', 'pam', 'dwight', 'dynamic-dave', 'api-ashley', 'role-rick', 'permission-peter'].map((n) => + c.users.db.delete(n) + ) + ); + }) + ); + }); + afterAll(() => makeClient('admin-key').then((c) => c.roles.delete('test'))); }); diff --git a/src/users/types.ts b/src/users/types.ts index 097b1c57..b4a9d59d 100644 --- a/src/users/types.ts +++ b/src/users/types.ts @@ -1,6 +1,25 @@ +import { WeaviateUserTypeDB as UserTypeDB, WeaviateUserTypeInternal } from '../openapi/types.js'; import { Role } from '../roles/types.js'; export type User = { id: string; roles?: Role[]; }; + +export type UserDB = { + userType: UserTypeDB; + id: string; + roleNames: string[]; + active: boolean; +}; + +/** Optional arguments to /user/{type}/{username} enpoint. */ +export type GetAssignedRolesOptions = { + includePermissions?: boolean; +}; + +/** Optional arguments to /assign and /revoke endpoints. */ +export type AssignRevokeOptions = { userType?: WeaviateUserTypeInternal }; + +/** Optional arguments to /deactivate endpoint. */ +export type DeactivateOptions = { revokeKey?: boolean }; diff --git a/test/version.ts b/test/version.ts new file mode 100644 index 00000000..b34118ef --- /dev/null +++ b/test/version.ts @@ -0,0 +1,7 @@ +import { DbVersion } from '../src/utils/dbVersion'; + +const version = DbVersion.fromString(`v${process.env.WEAVIATE_VERSION!}`); + +/** Run the suite / test only for Weaviate version above this. */ +export const requireAtLeast = (...semver: [...Parameters]) => + version.isAtLeast(...semver) ? describe : describe.skip;