From 2ab578191b4b0ef5fe7b6e4070a801b68d4946af Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Mon, 31 Mar 2025 12:44:43 +0200 Subject: [PATCH 01/23] chore: update OpenAPI schema --- src/openapi/schema.ts | 427 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 424 insertions(+), 3 deletions(-) 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']; }; }; }; From 1929a2212546b70e80101c3dce31d152a92cbe38 Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Mon, 31 Mar 2025 13:40:31 +0200 Subject: [PATCH 02/23] feat: enable dynamic user management, add db/oidc namespaces --- src/classifications/scheduler.ts | 2 +- src/connection/http.ts | 78 +++++++++--------- src/openapi/schema.ts | 132 +++++++++++++++---------------- src/openapi/types.ts | 4 + src/roles/util.ts | 18 ++++- src/users/index.ts | 129 ++++++++++++++++++++++++++---- src/users/types.ts | 10 +++ 7 files changed, 252 insertions(+), 121 deletions(-) diff --git a/src/classifications/scheduler.ts b/src/classifications/scheduler.ts index a46045e3..c089eb88 100644 --- a/src/classifications/scheduler.ts +++ b/src/classifications/scheduler.ts @@ -101,7 +101,7 @@ export default class ClassificationsScheduler extends CommandBase { reject( new Error( "classification didn't finish within configured timeout, " + - 'set larger timeout with .withWaitTimeout(timeout)' + 'set larger timeout with .withWaitTimeout(timeout)' ) ); }, this.waitTimeout); diff --git a/src/connection/http.ts b/src/connection/http.ts index 5250d930..9c123644 100644 --- a/src/connection/http.ts +++ b/src/connection/http.ts @@ -116,10 +116,14 @@ 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) + 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; + }; + + postNoBody = (path: string): Promise => { + return this.postReturn(path, null); }; postEmpty = (path: string, payload: B): Promise => { @@ -372,46 +376,46 @@ const makeUrl = (basePath: string) => (path: string) => basePath + path; const checkStatus = (expectResponseBody: boolean) => - (res: Response) => { - if (res.status >= 400) { - return res.text().then((errText: string) => { - let err: string; - try { - // in case of invalid json response (like empty string) - err = JSON.stringify(JSON.parse(errText)); - } catch (e) { - err = errText; - } - if (res.status === 401) { - return Promise.reject(new WeaviateUnauthenticatedError(err)); - } else if (res.status === 403) { - return Promise.reject(new WeaviateInsufficientPermissionsError(403, err)); - } else { - return Promise.reject(new WeaviateUnexpectedStatusCodeError(res.status, err)); - } - }); - } - if (expectResponseBody) { - return res.json() as Promise; - } - return Promise.resolve(undefined); - }; + (res: Response) => { + if (res.status >= 400) { + return res.text().then((errText: string) => { + let err: string; + try { + // in case of invalid json response (like empty string) + err = JSON.stringify(JSON.parse(errText)); + } catch (e) { + err = errText; + } + if (res.status === 401) { + return Promise.reject(new WeaviateUnauthenticatedError(err)); + } else if (res.status === 403) { + return Promise.reject(new WeaviateInsufficientPermissionsError(403, err)); + } else { + return Promise.reject(new WeaviateUnexpectedStatusCodeError(res.status, err)); + } + }); + } + if (expectResponseBody) { + return res.json() as Promise; + } + return Promise.resolve(undefined); + }; const handleHeadResponse = (expectResponseBody: boolean) => - (res: Response) => { - if (res.status == 200 || res.status == 204 || res.status == 404) { - return Promise.resolve(res.status == 200 || res.status == 204); - } - return checkStatus(expectResponseBody)(res); - }; + (res: Response) => { + if (res.status == 200 || res.status == 204 || res.status == 404) { + return Promise.resolve(res.status == 200 || res.status == 204); + } + return checkStatus(expectResponseBody)(res); + }; const getAuthHeaders = (config: InternalConnectionParams, bearerToken: string) => bearerToken ? { - Authorization: `Bearer ${bearerToken}`, - 'X-Weaviate-Cluster-Url': config.host, - // keeping for backwards compatibility for older clusters for now. On newer clusters, Embedding Service reuses Authorization header. - 'X-Weaviate-Api-Key': bearerToken, - } + Authorization: `Bearer ${bearerToken}`, + 'X-Weaviate-Cluster-Url': config.host, + // keeping for backwards compatibility for older clusters for now. On newer clusters, Embedding Service reuses Authorization header. + 'X-Weaviate-Api-Key': bearerToken, + } : undefined; diff --git a/src/openapi/schema.ts b/src/openapi/schema.ts index b5c11aa5..c3dabef0 100644 --- a/src/openapi/schema.ts +++ b/src/openapi/schema.ts @@ -386,30 +386,30 @@ export interface definitions { * @enum {string} */ action: - | 'manage_backups' - | 'read_cluster' - | 'create_data' - | 'read_data' - | 'update_data' - | 'delete_data' - | 'read_nodes' - | 'create_roles' - | 'read_roles' - | 'update_roles' - | 'delete_roles' - | 'create_collections' - | 'read_collections' - | 'update_collections' - | 'delete_collections' - | 'assign_and_revoke_users' - | 'create_users' - | 'read_users' - | 'update_users' - | 'delete_users' - | 'create_tenants' - | 'read_tenants' - | 'update_tenants' - | 'delete_tenants'; + | 'manage_backups' + | 'read_cluster' + | 'create_data' + | 'read_data' + | 'update_data' + | 'delete_data' + | 'read_nodes' + | 'create_roles' + | 'read_roles' + | 'update_roles' + | 'delete_roles' + | 'create_collections' + | 'read_collections' + | 'update_collections' + | 'delete_collections' + | 'assign_and_revoke_users' + | 'create_users' + | 'read_users' + | 'update_users' + | 'delete_users' + | 'create_tenants' + | 'read_tenants' + | 'update_tenants' + | 'delete_tenants'; }; /** @description list of roles */ RolesListResponse: definitions['Role'][]; @@ -787,15 +787,15 @@ export interface definitions { * @enum {string} */ tokenization?: - | 'word' - | 'lowercase' - | 'whitespace' - | 'field' - | 'trigram' - | 'gse' - | 'kagome_kr' - | 'kagome_ja' - | 'gse_ch'; + | 'word' + | 'lowercase' + | 'whitespace' + | 'field' + | 'trigram' + | 'gse' + | 'kagome_kr' + | 'kagome_ja' + | 'gse_ch'; /** @description The properties of the nested object(s). Applies to object and object[] data types. */ nestedProperties?: definitions['NestedProperty'][]; }; @@ -816,15 +816,15 @@ export interface definitions { indexRangeFilters?: boolean; /** @enum {string} */ tokenization?: - | 'word' - | 'lowercase' - | 'whitespace' - | 'field' - | 'trigram' - | 'gse' - | 'kagome_kr' - | 'kagome_ja' - | 'gse_ch'; + | 'word' + | 'lowercase' + | 'whitespace' + | 'field' + | 'trigram' + | 'gse' + | 'kagome_kr' + | 'kagome_ja' + | 'gse_ch'; /** @description The properties of the nested object(s). Applies to object and object[] data types. */ nestedProperties?: definitions['NestedProperty'][]; }; @@ -1505,19 +1505,19 @@ export interface definitions { * @enum {string} */ operator?: - | 'And' - | 'Or' - | 'Equal' - | 'Like' - | 'NotEqual' - | 'GreaterThan' - | 'GreaterThanEqual' - | 'LessThan' - | 'LessThanEqual' - | 'WithinGeoRange' - | 'IsNull' - | 'ContainsAny' - | 'ContainsAll'; + | 'And' + | 'Or' + | 'Equal' + | 'Like' + | 'NotEqual' + | 'GreaterThan' + | 'GreaterThanEqual' + | 'LessThan' + | 'LessThanEqual' + | 'WithinGeoRange' + | 'IsNull' + | 'ContainsAny' + | 'ContainsAll'; /** * @description path to the property currently being filtered * @example [ @@ -1618,16 +1618,16 @@ export interface definitions { * @enum {string} */ activityStatus?: - | 'ACTIVE' - | 'INACTIVE' - | 'OFFLOADED' - | 'OFFLOADING' - | 'ONLOADING' - | 'HOT' - | 'COLD' - | 'FROZEN' - | 'FREEZING' - | 'UNFREEZING'; + | 'ACTIVE' + | 'INACTIVE' + | 'OFFLOADED' + | 'OFFLOADING' + | 'ONLOADING' + | 'HOT' + | 'COLD' + | 'FROZEN' + | 'FREEZING' + | 'UNFREEZING'; }; /** @description attributes representing a single tenant response within weaviate */ TenantResponse: definitions['Tenant'] & { @@ -4187,4 +4187,4 @@ export interface operations { }; } -export interface external {} +export interface external { } diff --git a/src/openapi/types.ts b/src/openapi/types.ts index 054ab10d..4924fc90 100644 --- a/src/openapi/types.ts +++ b/src/openapi/types.ts @@ -55,6 +55,10 @@ export type WeaviateReplicationConfig = WeaviateClass['replicationConfig']; export type WeaviateShardingConfig = WeaviateClass['shardingConfig']; export type WeaviateShardStatus = definitions['ShardStatusGetResponse']; 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 WeaviateVectorIndexConfig = WeaviateClass['vectorIndexConfig']; export type WeaviateVectorsConfig = WeaviateClass['vectorConfig']; export type WeaviateVectorConfig = definitions['VectorConfig']; diff --git a/src/roles/util.ts b/src/roles/util.ts index bee179db..dad7159e 100644 --- a/src/roles/util.ts +++ b/src/roles/util.ts @@ -1,5 +1,5 @@ -import { Permission as WeaviatePermission, Role as WeaviateRole, WeaviateUser } from '../openapi/types.js'; -import { User } from '../users/types.js'; +import { WeaviateDBUser, Permission as WeaviatePermission, Role as WeaviateRole, WeaviateUser } from '../openapi/types.js'; +import { User, UserDB } from '../users/types.js'; import { BackupsAction, BackupsPermission, @@ -136,7 +136,19 @@ export class Map { 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.reduce((acc, user) => { + acc.push(Map.dbUser(user)); + return acc; + }, [] as UserDB[]) + ; } class PermissionsMapping { diff --git a/src/users/index.ts b/src/users/index.ts index 353909fc..79d9b0b9 100644 --- a/src/users/index.ts +++ b/src/users/index.ts @@ -1,10 +1,33 @@ import { ConnectionREST } from '../index.js'; -import { Role as WeaviateRole, WeaviateUser } from '../openapi/types.js'; +import { Role as WeaviateRole, WeaviateUserTypeInternal as UserTypeInternal, WeaviateUser, WeaviateDBUser } from '../openapi/types.js'; import { Role } from '../roles/types.js'; import { Map } from '../roles/util.js'; -import { User } from './types.js'; +import { 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,38 +41,116 @@ 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 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, includePermissions?: boolean) => Promise>; + + create: (userId: string) => Promise; + delete: (userId: string) => Promise; + rotateKey: (userId: string) => Promise; + activate: (userId: string) => Promise; + deactivate: (userId: string) => Promise; + byName: (userId: string) => Promise; + listAll: () => Promise; +} + +/** Operations supported for namespaced 'oidc' users.*/ +export interface OIDCUsers extends UsersBase { /** - * Revoke roles from a user. + * Retrieve the roles assigned to 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. + * @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. */ - revokeRoles: (roleNames: string | string[], userId: string) => Promise; + getAssignedRoles: (userId: string, includePermissions?: boolean) => Promise>; } const users = (connection: ConnectionREST): Users => { + const ns = namespaced(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) => + ns.assignRoles(roleNames, userId), + revokeRoles: (roleNames: string | string[], userId: string) => + ns.revokeRoles(roleNames, userId), + db: db(connection), + oidc: oidc(connection), + }; +}; + +const db = (connection: ConnectionREST): DBUsers => { + const ns = namespaced(connection); + + type APIKeyResponse = { apiKey: string }; + return { + getAssignedRoles: (userId: string, includePermissions?: boolean) => ns.getAssignedRoles(userId, 'db', includePermissions), + assignRoles: (roleNames: string | string[], userId: string) => ns.assignRoles(roleNames, userId, 'db'), + revokeRoles: (roleNames: string | string[], userId: string) => ns.revokeRoles(roleNames, userId, 'db'), + + create: (userId: string) => connection.postNoBody(`/users/db/${userId}`) + .then(resp => resp.apiKey), + delete: (userId: string) => connection.delete(`/users/db/${userId}`, null) + .then(() => true).catch(() => false), + rotateKey: (userId: string) => connection.postNoBody(`users/db/${userId}/rotate-key`) + .then(resp => resp.apiKey), + activate: (userId: string) => connection.postNoBody(`/users/db/${userId}/activate`) + .then(() => true).catch(reason => reason.code !== undefined ? reason.code === 409 : false), + deactivate: (userId: string) => connection.postNoBody(`/users/db/${userId}/deactivate`) + .then(() => true).catch(reason => reason.code !== undefined ? reason.code === 409 : false), + 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 = namespaced(connection); + return { + getAssignedRoles: (userId: string, includePermissions?: boolean) => ns.getAssignedRoles(userId, 'oidc', includePermissions), + assignRoles: (roleNames: string | string[], userId: string) => ns.assignRoles(roleNames, userId, 'oidc'), + revokeRoles: (roleNames: string | string[], userId: string) => ns.revokeRoles(roleNames, userId, 'oidc'), + }; +} + +// TODO: see if we can extend definitions of UsersBase with additional UserType arg +/** Internal interface for operations that MAY accept a 'db'/'oidc' namespace. */ +interface NamespacedUsers { + getAssignedRoles: (userId: string, userType: UserTypeInternal, includePermissions?: boolean) => Promise>; + assignRoles: (roleNames: string | string[], userId: string, userType?: UserTypeInternal) => Promise; + revokeRoles: (roleNames: string | string[], userId: string, userType?: UserTypeInternal) => Promise; +} + +const namespaced = (connection: ConnectionREST): NamespacedUsers => { + return { + getAssignedRoles: (userId: string, userType: UserTypeInternal, includePermissions?: boolean) => + connection.get( + `/authz/users/${userId}/roles/${userType}${includePermissions ? '?&includeFullRoles=true' : ''}` + ).then(Map.roles), + assignRoles: (roleNames: string | string[], userId: string, userType?: UserTypeInternal) => connection.postEmpty(`/authz/users/${userId}/assign`, { roles: Array.isArray(roleNames) ? roleNames : [roleNames], + userType: userType }), - revokeRoles: (roleNames: string | string[], userId: string) => + revokeRoles: (roleNames: string | string[], userId: string, userType?: UserTypeInternal) => connection.postEmpty(`/authz/users/${userId}/revoke`, { roles: Array.isArray(roleNames) ? roleNames : [roleNames], + userType: userType, }), }; }; + export default users; diff --git a/src/users/types.ts b/src/users/types.ts index 097b1c57..a14dce06 100644 --- a/src/users/types.ts +++ b/src/users/types.ts @@ -1,6 +1,16 @@ import { Role } from '../roles/types.js'; +import { WeaviateUserTypeDB as UserTypeDB } from '../openapi/types.js'; export type User = { id: string; roles?: Role[]; }; + +export type UserDB = { + userType: UserTypeDB; + id: string; + roleNames: string[]; + active: boolean; +}; + + From a9cf76c8df81b2b9027f8bd6eade4c9d7709ef05 Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Mon, 31 Mar 2025 14:33:58 +0200 Subject: [PATCH 03/23] refactor: re-use common code snippets --- src/users/index.ts | 42 ++++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/src/users/index.ts b/src/users/index.ts index 79d9b0b9..b6410995 100644 --- a/src/users/index.ts +++ b/src/users/index.ts @@ -1,5 +1,6 @@ import { ConnectionREST } from '../index.js'; import { Role as WeaviateRole, WeaviateUserTypeInternal as UserTypeInternal, WeaviateUser, WeaviateDBUser } from '../openapi/types.js'; +import roles from '../roles/index.js'; import { Role } from '../roles/types.js'; import { Map } from '../roles/util.js'; import { User, UserDB } from './types.js'; @@ -77,27 +78,31 @@ export interface OIDCUsers extends UsersBase { } const users = (connection: ConnectionREST): Users => { - const ns = namespaced(connection); + 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) => - ns.assignRoles(roleNames, userId), + base.assignRoles(roleNames, userId), revokeRoles: (roleNames: string | string[], userId: string) => - ns.revokeRoles(roleNames, userId), + base.revokeRoles(roleNames, userId), db: db(connection), oidc: oidc(connection), }; }; const db = (connection: ConnectionREST): DBUsers => { - const ns = namespaced(connection); + const ns = namespacedUsers(connection); + + const allowCode = (code: number): (reason: any) => boolean => { + return reason => reason.code !== undefined && reason.code === code; + } type APIKeyResponse = { apiKey: string }; return { - getAssignedRoles: (userId: string, includePermissions?: boolean) => ns.getAssignedRoles(userId, 'db', includePermissions), + getAssignedRoles: (userId: string, includePermissions?: boolean) => ns.getAssignedRoles('db', userId, includePermissions), assignRoles: (roleNames: string | string[], userId: string) => ns.assignRoles(roleNames, userId, 'db'), revokeRoles: (roleNames: string | string[], userId: string) => ns.revokeRoles(roleNames, userId, 'db'), @@ -108,41 +113,50 @@ const db = (connection: ConnectionREST): DBUsers => { rotateKey: (userId: string) => connection.postNoBody(`users/db/${userId}/rotate-key`) .then(resp => resp.apiKey), activate: (userId: string) => connection.postNoBody(`/users/db/${userId}/activate`) - .then(() => true).catch(reason => reason.code !== undefined ? reason.code === 409 : false), + .then(() => true).catch(allowCode(409)), deactivate: (userId: string) => connection.postNoBody(`/users/db/${userId}/deactivate`) - .then(() => true).catch(reason => reason.code !== undefined ? reason.code === 409 : false), + .then(() => true).catch(allowCode(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 = namespaced(connection); + const ns = namespacedUsers(connection); return { - getAssignedRoles: (userId: string, includePermissions?: boolean) => ns.getAssignedRoles(userId, 'oidc', includePermissions), + getAssignedRoles: (userId: string, includePermissions?: boolean) => ns.getAssignedRoles('oidc', userId, includePermissions), assignRoles: (roleNames: string | string[], userId: string) => ns.assignRoles(roleNames, userId, 'oidc'), revokeRoles: (roleNames: string | string[], userId: string) => ns.revokeRoles(roleNames, userId, 'oidc'), }; } -// TODO: see if we can extend definitions of UsersBase with additional UserType arg /** Internal interface for operations that MAY accept a 'db'/'oidc' namespace. */ interface NamespacedUsers { - getAssignedRoles: (userId: string, userType: UserTypeInternal, includePermissions?: boolean) => Promise>; + getAssignedRoles: (userType: UserTypeInternal, userId: string, includePermissions?: boolean) => Promise>; assignRoles: (roleNames: string | string[], userId: string, userType?: UserTypeInternal) => Promise; revokeRoles: (roleNames: string | string[], userId: string, userType?: UserTypeInternal) => Promise; } -const namespaced = (connection: ConnectionREST): NamespacedUsers => { +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), + }; +} + +const namespacedUsers = (connection: ConnectionREST): NamespacedUsers => { return { - getAssignedRoles: (userId: string, userType: UserTypeInternal, includePermissions?: boolean) => + getAssignedRoles: (userType: UserTypeInternal, userId: string, includePermissions?: boolean) => connection.get( `/authz/users/${userId}/roles/${userType}${includePermissions ? '?&includeFullRoles=true' : ''}` ).then(Map.roles), assignRoles: (roleNames: string | string[], userId: string, userType?: UserTypeInternal) => connection.postEmpty(`/authz/users/${userId}/assign`, { roles: Array.isArray(roleNames) ? roleNames : [roleNames], - userType: userType + userType: userType, }), revokeRoles: (roleNames: string | string[], userId: string, userType?: UserTypeInternal) => connection.postEmpty(`/authz/users/${userId}/revoke`, { From fd3ff062b7a03234374a1689840dc606bb9eb6b3 Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Mon, 31 Mar 2025 17:54:46 +0200 Subject: [PATCH 04/23] test: extend integration test suite --- .github/workflows/main.yaml | 4 +- ci/compose.sh | 0 ci/docker-compose-rbac.yml | 6 ++ src/classifications/scheduler.ts | 2 +- src/connection/http.ts | 74 +++++++++-------- src/openapi/schema.ts | 132 +++++++++++++++---------------- src/roles/util.ts | 20 +++-- src/users/index.ts | 94 +++++++++++++--------- src/users/integration.test.ts | 104 +++++++++++++++++++++++- src/users/types.ts | 4 +- 10 files changed, 281 insertions(+), 159 deletions(-) mode change 100644 => 100755 ci/compose.sh 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/classifications/scheduler.ts b/src/classifications/scheduler.ts index c089eb88..a46045e3 100644 --- a/src/classifications/scheduler.ts +++ b/src/classifications/scheduler.ts @@ -101,7 +101,7 @@ export default class ClassificationsScheduler extends CommandBase { reject( new Error( "classification didn't finish within configured timeout, " + - 'set larger timeout with .withWaitTimeout(timeout)' + 'set larger timeout with .withWaitTimeout(timeout)' ) ); }, this.waitTimeout); diff --git a/src/connection/http.ts b/src/connection/http.ts index 9c123644..621a3f3b 100644 --- a/src/connection/http.ts +++ b/src/connection/http.ts @@ -115,9 +115,7 @@ 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) as T - ); + return this.login().then((token) => this.http.post(path, payload, true, token) as T); } return this.http.post(path, payload, true, '') as Promise; }; @@ -376,46 +374,46 @@ const makeUrl = (basePath: string) => (path: string) => basePath + path; const checkStatus = (expectResponseBody: boolean) => - (res: Response) => { - if (res.status >= 400) { - return res.text().then((errText: string) => { - let err: string; - try { - // in case of invalid json response (like empty string) - err = JSON.stringify(JSON.parse(errText)); - } catch (e) { - err = errText; - } - if (res.status === 401) { - return Promise.reject(new WeaviateUnauthenticatedError(err)); - } else if (res.status === 403) { - return Promise.reject(new WeaviateInsufficientPermissionsError(403, err)); - } else { - return Promise.reject(new WeaviateUnexpectedStatusCodeError(res.status, err)); - } - }); - } - if (expectResponseBody) { - return res.json() as Promise; - } - return Promise.resolve(undefined); - }; + (res: Response) => { + if (res.status >= 400) { + return res.text().then((errText: string) => { + let err: string; + try { + // in case of invalid json response (like empty string) + err = JSON.stringify(JSON.parse(errText)); + } catch (e) { + err = errText; + } + if (res.status === 401) { + return Promise.reject(new WeaviateUnauthenticatedError(err)); + } else if (res.status === 403) { + return Promise.reject(new WeaviateInsufficientPermissionsError(403, err)); + } else { + return Promise.reject(new WeaviateUnexpectedStatusCodeError(res.status, err)); + } + }); + } + if (expectResponseBody) { + return res.json() as Promise; + } + return Promise.resolve(undefined); + }; const handleHeadResponse = (expectResponseBody: boolean) => - (res: Response) => { - if (res.status == 200 || res.status == 204 || res.status == 404) { - return Promise.resolve(res.status == 200 || res.status == 204); - } - return checkStatus(expectResponseBody)(res); - }; + (res: Response) => { + if (res.status == 200 || res.status == 204 || res.status == 404) { + return Promise.resolve(res.status == 200 || res.status == 204); + } + return checkStatus(expectResponseBody)(res); + }; const getAuthHeaders = (config: InternalConnectionParams, bearerToken: string) => bearerToken ? { - Authorization: `Bearer ${bearerToken}`, - 'X-Weaviate-Cluster-Url': config.host, - // keeping for backwards compatibility for older clusters for now. On newer clusters, Embedding Service reuses Authorization header. - 'X-Weaviate-Api-Key': bearerToken, - } + Authorization: `Bearer ${bearerToken}`, + 'X-Weaviate-Cluster-Url': config.host, + // keeping for backwards compatibility for older clusters for now. On newer clusters, Embedding Service reuses Authorization header. + 'X-Weaviate-Api-Key': bearerToken, + } : undefined; diff --git a/src/openapi/schema.ts b/src/openapi/schema.ts index c3dabef0..b5c11aa5 100644 --- a/src/openapi/schema.ts +++ b/src/openapi/schema.ts @@ -386,30 +386,30 @@ export interface definitions { * @enum {string} */ action: - | 'manage_backups' - | 'read_cluster' - | 'create_data' - | 'read_data' - | 'update_data' - | 'delete_data' - | 'read_nodes' - | 'create_roles' - | 'read_roles' - | 'update_roles' - | 'delete_roles' - | 'create_collections' - | 'read_collections' - | 'update_collections' - | 'delete_collections' - | 'assign_and_revoke_users' - | 'create_users' - | 'read_users' - | 'update_users' - | 'delete_users' - | 'create_tenants' - | 'read_tenants' - | 'update_tenants' - | 'delete_tenants'; + | 'manage_backups' + | 'read_cluster' + | 'create_data' + | 'read_data' + | 'update_data' + | 'delete_data' + | 'read_nodes' + | 'create_roles' + | 'read_roles' + | 'update_roles' + | 'delete_roles' + | 'create_collections' + | 'read_collections' + | 'update_collections' + | 'delete_collections' + | 'assign_and_revoke_users' + | 'create_users' + | 'read_users' + | 'update_users' + | 'delete_users' + | 'create_tenants' + | 'read_tenants' + | 'update_tenants' + | 'delete_tenants'; }; /** @description list of roles */ RolesListResponse: definitions['Role'][]; @@ -787,15 +787,15 @@ export interface definitions { * @enum {string} */ tokenization?: - | 'word' - | 'lowercase' - | 'whitespace' - | 'field' - | 'trigram' - | 'gse' - | 'kagome_kr' - | 'kagome_ja' - | 'gse_ch'; + | 'word' + | 'lowercase' + | 'whitespace' + | 'field' + | 'trigram' + | 'gse' + | 'kagome_kr' + | 'kagome_ja' + | 'gse_ch'; /** @description The properties of the nested object(s). Applies to object and object[] data types. */ nestedProperties?: definitions['NestedProperty'][]; }; @@ -816,15 +816,15 @@ export interface definitions { indexRangeFilters?: boolean; /** @enum {string} */ tokenization?: - | 'word' - | 'lowercase' - | 'whitespace' - | 'field' - | 'trigram' - | 'gse' - | 'kagome_kr' - | 'kagome_ja' - | 'gse_ch'; + | 'word' + | 'lowercase' + | 'whitespace' + | 'field' + | 'trigram' + | 'gse' + | 'kagome_kr' + | 'kagome_ja' + | 'gse_ch'; /** @description The properties of the nested object(s). Applies to object and object[] data types. */ nestedProperties?: definitions['NestedProperty'][]; }; @@ -1505,19 +1505,19 @@ export interface definitions { * @enum {string} */ operator?: - | 'And' - | 'Or' - | 'Equal' - | 'Like' - | 'NotEqual' - | 'GreaterThan' - | 'GreaterThanEqual' - | 'LessThan' - | 'LessThanEqual' - | 'WithinGeoRange' - | 'IsNull' - | 'ContainsAny' - | 'ContainsAll'; + | 'And' + | 'Or' + | 'Equal' + | 'Like' + | 'NotEqual' + | 'GreaterThan' + | 'GreaterThanEqual' + | 'LessThan' + | 'LessThanEqual' + | 'WithinGeoRange' + | 'IsNull' + | 'ContainsAny' + | 'ContainsAll'; /** * @description path to the property currently being filtered * @example [ @@ -1618,16 +1618,16 @@ export interface definitions { * @enum {string} */ activityStatus?: - | 'ACTIVE' - | 'INACTIVE' - | 'OFFLOADED' - | 'OFFLOADING' - | 'ONLOADING' - | 'HOT' - | 'COLD' - | 'FROZEN' - | 'FREEZING' - | 'UNFREEZING'; + | 'ACTIVE' + | 'INACTIVE' + | 'OFFLOADED' + | 'OFFLOADING' + | 'ONLOADING' + | 'HOT' + | 'COLD' + | 'FROZEN' + | 'FREEZING' + | 'UNFREEZING'; }; /** @description attributes representing a single tenant response within weaviate */ TenantResponse: definitions['Tenant'] & { @@ -4187,4 +4187,4 @@ export interface operations { }; } -export interface external { } +export interface external {} diff --git a/src/roles/util.ts b/src/roles/util.ts index dad7159e..b6ff39d2 100644 --- a/src/roles/util.ts +++ b/src/roles/util.ts @@ -1,4 +1,9 @@ -import { WeaviateDBUser, Permission as WeaviatePermission, Role as WeaviateRole, WeaviateUser } from '../openapi/types.js'; +import { + WeaviateDBUser, + Permission as WeaviatePermission, + Role as WeaviateRole, + WeaviateUser, +} from '../openapi/types.js'; import { User, UserDB } from '../users/types.js'; import { BackupsAction, @@ -136,19 +141,18 @@ export class Map { 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.reduce((acc, user) => { acc.push(Map.dbUser(user)); return acc; - }, [] as UserDB[]) - ; + }, [] as UserDB[]); } class PermissionsMapping { @@ -172,7 +176,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 b6410995..5e2b9a35 100644 --- a/src/users/index.ts +++ b/src/users/index.ts @@ -1,14 +1,18 @@ import { ConnectionREST } from '../index.js'; -import { Role as WeaviateRole, WeaviateUserTypeInternal as UserTypeInternal, WeaviateUser, WeaviateDBUser } from '../openapi/types.js'; -import roles from '../roles/index.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, UserDB } from './types.js'; /** -* Operations supported for 'db', 'oidc', and legacy (non-namespaced) users. -* Use respective implementations in `users.db` and `users.oidc`, and `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. @@ -43,8 +47,8 @@ export interface Users extends UsersBase { */ getAssignedRoles: (userId: string) => Promise>; - db: DBUsers, - oidc: OIDCUsers, + db: DBUsers; + oidc: OIDCUsers; } /** Operations supported for namespaced 'db' users.*/ @@ -84,10 +88,8 @@ const users = (connection: ConnectionREST): Users => { 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), + 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), }; @@ -96,43 +98,58 @@ const users = (connection: ConnectionREST): Users => { const db = (connection: ConnectionREST): DBUsers => { const ns = namespacedUsers(connection); - const allowCode = (code: number): (reason: any) => boolean => { - return reason => reason.code !== undefined && reason.code === code; - } + const allowCode = (code: number): ((reason: any) => boolean) => { + return (reason) => reason.code !== undefined && reason.code === code; + }; - type APIKeyResponse = { apiKey: string }; + type APIKeyResponse = { apikey: string }; return { - getAssignedRoles: (userId: string, includePermissions?: boolean) => ns.getAssignedRoles('db', userId, includePermissions), + getAssignedRoles: (userId: string, includePermissions?: boolean) => + ns.getAssignedRoles('db', userId, includePermissions), assignRoles: (roleNames: string | string[], userId: string) => ns.assignRoles(roleNames, userId, 'db'), revokeRoles: (roleNames: string | string[], userId: string) => ns.revokeRoles(roleNames, userId, 'db'), - create: (userId: string) => connection.postNoBody(`/users/db/${userId}`) - .then(resp => resp.apiKey), - delete: (userId: string) => connection.delete(`/users/db/${userId}`, null) - .then(() => true).catch(() => false), - rotateKey: (userId: string) => connection.postNoBody(`users/db/${userId}/rotate-key`) - .then(resp => resp.apiKey), - activate: (userId: string) => connection.postNoBody(`/users/db/${userId}/activate`) - .then(() => true).catch(allowCode(409)), - deactivate: (userId: string) => connection.postNoBody(`/users/db/${userId}/deactivate`) - .then(() => true).catch(allowCode(409)), + create: (userId: string) => + connection.postNoBody(`/users/db/${userId}`).then((resp) => resp.apikey), + delete: (userId: string) => + connection + .delete(`/users/db/${userId}`, null) + .then(() => true) + .catch(() => false), + rotateKey: (userId: string) => + connection.postNoBody(`/users/db/${userId}/rotate-key`).then((resp) => resp.apikey), + activate: (userId: string) => + connection + .postNoBody(`/users/db/${userId}/activate`) + .then(() => true) + .catch(allowCode(409)), + deactivate: (userId: string) => + connection + .postNoBody(`/users/db/${userId}/deactivate`) + .then(() => true) + .catch(allowCode(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, includePermissions?: boolean) => ns.getAssignedRoles('oidc', userId, includePermissions), + getAssignedRoles: (userId: string, includePermissions?: boolean) => + ns.getAssignedRoles('oidc', userId, includePermissions), assignRoles: (roleNames: string | string[], userId: string) => ns.assignRoles(roleNames, userId, 'oidc'), revokeRoles: (roleNames: string | string[], userId: string) => ns.revokeRoles(roleNames, userId, 'oidc'), }; -} +}; /** Internal interface for operations that MAY accept a 'db'/'oidc' namespace. */ interface NamespacedUsers { - getAssignedRoles: (userType: UserTypeInternal, userId: string, includePermissions?: boolean) => Promise>; + getAssignedRoles: ( + userType: UserTypeInternal, + userId: string, + includePermissions?: boolean + ) => Promise>; assignRoles: (roleNames: string | string[], userId: string, userType?: UserTypeInternal) => Promise; revokeRoles: (roleNames: string | string[], userId: string, userType?: UserTypeInternal) => Promise; } @@ -140,19 +157,19 @@ interface NamespacedUsers { 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), + assignRoles: (roleNames: string | string[], userId: string) => ns.assignRoles(roleNames, userId), + revokeRoles: (roleNames: string | string[], userId: string) => ns.revokeRoles(roleNames, userId), }; -} +}; const namespacedUsers = (connection: ConnectionREST): NamespacedUsers => { return { getAssignedRoles: (userType: UserTypeInternal, userId: string, includePermissions?: boolean) => - connection.get( - `/authz/users/${userId}/roles/${userType}${includePermissions ? '?&includeFullRoles=true' : ''}` - ).then(Map.roles), + connection + .get( + `/authz/users/${userId}/roles/${userType}${includePermissions ? '?&includeFullRoles=true' : ''}` + ) + .then(Map.roles), assignRoles: (roleNames: string | string[], userId: string, userType?: UserTypeInternal) => connection.postEmpty(`/authz/users/${userId}/assign`, { roles: Array.isArray(roleNames) ? roleNames : [roleNames], @@ -166,5 +183,4 @@ const namespacedUsers = (connection: ConnectionREST): NamespacedUsers => { }; }; - export default users; diff --git a/src/users/integration.test.ts b/src/users/integration.test.ts index 83d2ec4a..56c8cbb8 100644 --- a/src/users/integration.test.ts +++ b/src/users/integration.test.ts @@ -1,11 +1,19 @@ import weaviate, { ApiKey } from '..'; import { DbVersion } from '../utils/dbVersion'; +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; +const version = DbVersion.fromString(`v${process.env.WEAVIATE_VERSION!}`); -only('Integration testing of the users namespace', () => { +/** Run the suite / test only for Weaviate version above this. */ +const requireAtLeast = (...semver: [...Parameters]) => + version.isAtLeast(...semver) ? describe : describe.skip; + +requireAtLeast( + 1, + 29, + 0 +)('Integration testing of the users namespace', () => { const makeClient = (key: string) => weaviate.connectToLocal({ port: 8091, @@ -59,5 +67,93 @@ 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'); + expectDave().toHaveProperty('active', true); + + // Second activation is a no-op + expect(client.users.db.activate('dynamic-dave')).resolves.toEqual(true); + + await client.users.db.deactivate('dynamic-dave'); + expectDave().toHaveProperty('active', false); + + // Second deactivation is a no-op + expect(client.users.db.deactivate('dynamic-dave')).resolves.toEqual(true); + + await client.users.db.delete('dynamic-dave'); + 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'); + + const created: Promise[] = []; + for (const user of ['jim', 'pam', 'dwight']) { + created.push(admin.users.db.create(user)); + } + await Promise.all(created); + + 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.db.assignRoles('test', 'role-rick'); + expect(admin.users.db.getAssignedRoles('role-rick')).resolves.toEqual( + expect.objectContaining({ test: expect.any(Object) }) + ); + + await admin.users.db.revokeRoles('test', 'role-rick'); + expect(admin.users.db.getAssignedRoles('role-rick')).resolves.toEqual({}); + }); + + afterAll(() => + makeClient('admin-key').then((c) => { + for (const user of ['jim', 'pam', 'dwight', 'dynamic-dave', 'api-ashley', 'role-rick']) { + c.users.db.delete(user); + } + }) + ); + }); + afterAll(() => makeClient('admin-key').then((c) => c.roles.delete('test'))); }); diff --git a/src/users/types.ts b/src/users/types.ts index a14dce06..01f4c85b 100644 --- a/src/users/types.ts +++ b/src/users/types.ts @@ -1,5 +1,5 @@ -import { Role } from '../roles/types.js'; import { WeaviateUserTypeDB as UserTypeDB } from '../openapi/types.js'; +import { Role } from '../roles/types.js'; export type User = { id: string; @@ -12,5 +12,3 @@ export type UserDB = { roleNames: string[]; active: boolean; }; - - From b1ece85707ed42060f9261c0137e88017be95594 Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Mon, 31 Mar 2025 21:05:03 +0200 Subject: [PATCH 05/23] refactor(test): use concise Promise.all --- src/users/integration.test.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/users/integration.test.ts b/src/users/integration.test.ts index 56c8cbb8..02338f42 100644 --- a/src/users/integration.test.ts +++ b/src/users/integration.test.ts @@ -112,11 +112,7 @@ requireAtLeast( it('should be able to list all dynamic users', async () => { const admin = await makeClient('admin-key'); - const created: Promise[] = []; - for (const user of ['jim', 'pam', 'dwight']) { - created.push(admin.users.db.create(user)); - } - await Promise.all(created); + 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); @@ -147,10 +143,10 @@ requireAtLeast( }); afterAll(() => - makeClient('admin-key').then((c) => { - for (const user of ['jim', 'pam', 'dwight', 'dynamic-dave', 'api-ashley', 'role-rick']) { - c.users.db.delete(user); - } + makeClient('admin-key').then(async (c) => { + await Promise.all( + ['jim', 'pam', 'dwight', 'dynamic-dave', 'api-ashley', 'role-rick'].map((n) => c.roles.delete(n)) + ); }) ); }); From d61c30fbf3ab0ea18225d86f734ba6287d6f7fdd Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Mon, 31 Mar 2025 21:47:21 +0200 Subject: [PATCH 06/23] feat(breaking): include user types in user assignments --- src/openapi/types.ts | 13 ++++++----- src/roles/index.ts | 14 ++++++++--- src/roles/integration.test.ts | 44 ++++++++++++++++++++++++++++++----- src/roles/types.ts | 7 +++++- src/roles/util.ts | 11 +++++++++ src/users/integration.test.ts | 8 +------ test/version.ts | 7 ++++++ 7 files changed, 81 insertions(+), 23 deletions(-) create mode 100644 test/version.ts diff --git a/src/openapi/types.ts b/src/openapi/types.ts index 4924fc90..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,11 +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 WeaviateDBUser = definitions['DBUserInfo']; -export type WeaviateUserType = definitions['UserTypeOutput']; -export type WeaviateUserTypeInternal = definitions['UserTypeInput']; -export type WeaviateUserTypeDB = definitions['DBUserInfo']['dbUserType']; export type WeaviateVectorIndexConfig = WeaviateClass['vectorIndexConfig']; export type WeaviateVectorsConfig = WeaviateClass['vectorConfig']; export type WeaviateVectorConfig = definitions['VectorConfig']; @@ -73,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..139e9dee 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'; @@ -35,7 +40,7 @@ export interface Roles { * @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 +94,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..a2b6456d 100644 --- a/src/roles/integration.test.ts +++ b/src/roles/integration.test.ts @@ -7,9 +7,10 @@ import weaviate, { RolesAction, TenantsAction, WeaviateClient, + UserAssignment, } 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,37 @@ 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 b6ff39d2..53571437 100644 --- a/src/roles/util.ts +++ b/src/roles/util.ts @@ -1,4 +1,5 @@ import { + WeaviateAssignedUser, WeaviateDBUser, Permission as WeaviatePermission, Role as WeaviateRole, @@ -23,6 +24,7 @@ import { RolesPermission, TenantsAction, TenantsPermission, + UserAssignment, UsersAction, UsersPermission, } from './types.js'; @@ -153,6 +155,15 @@ export class Map { acc.push(Map.dbUser(user)); return acc; }, [] as UserDB[]); + + static assignedUsers = (users: WeaviateAssignedUser[]): UserAssignment[] => + users.reduce((acc, user) => { + acc.push({ + id: user.userId || '', + userType: user.userType, + }); + return acc; + }, [] as UserAssignment[]); } class PermissionsMapping { diff --git a/src/users/integration.test.ts b/src/users/integration.test.ts index 02338f42..305bc371 100644 --- a/src/users/integration.test.ts +++ b/src/users/integration.test.ts @@ -1,14 +1,8 @@ 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 version = DbVersion.fromString(`v${process.env.WEAVIATE_VERSION!}`); - -/** Run the suite / test only for Weaviate version above this. */ -const requireAtLeast = (...semver: [...Parameters]) => - version.isAtLeast(...semver) ? describe : describe.skip; - requireAtLeast( 1, 29, 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; From 795fdb7a125bd9983afa152198299bbb67c982b2 Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Tue, 1 Apr 2025 13:42:32 +0200 Subject: [PATCH 07/23] chore: format and lint --- src/roles/integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/roles/integration.test.ts b/src/roles/integration.test.ts index a2b6456d..1d1b24b2 100644 --- a/src/roles/integration.test.ts +++ b/src/roles/integration.test.ts @@ -6,8 +6,8 @@ import weaviate, { Role, RolesAction, TenantsAction, - WeaviateClient, UserAssignment, + WeaviateClient, } from '..'; import { requireAtLeast } from '../../test/version'; import { WeaviateStartUpError, WeaviateUnexpectedStatusCodeError } from '../errors'; From 5562ec99c05062b5fdf8be95fd9067e61ddbd1b9 Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Tue, 1 Apr 2025 13:50:26 +0200 Subject: [PATCH 08/23] test: activate test case for 'oidc' users --- src/users/integration.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/users/integration.test.ts b/src/users/integration.test.ts index 305bc371..453d97dc 100644 --- a/src/users/integration.test.ts +++ b/src/users/integration.test.ts @@ -127,19 +127,19 @@ requireAtLeast( await admin.users.db.create('role-rick'); } - await admin.users.db.assignRoles('test', 'role-rick'); - expect(admin.users.db.getAssignedRoles('role-rick')).resolves.toEqual( + await admin.users[kind].assignRoles('test', 'role-rick'); + expect(admin.users[kind].getAssignedRoles('role-rick')).resolves.toEqual( expect.objectContaining({ test: expect.any(Object) }) ); - await admin.users.db.revokeRoles('test', 'role-rick'); - expect(admin.users.db.getAssignedRoles('role-rick')).resolves.toEqual({}); + await admin.users[kind].revokeRoles('test', 'role-rick'); + expect(admin.users[kind].getAssignedRoles('role-rick')).resolves.toEqual({}) }); afterAll(() => makeClient('admin-key').then(async (c) => { await Promise.all( - ['jim', 'pam', 'dwight', 'dynamic-dave', 'api-ashley', 'role-rick'].map((n) => c.roles.delete(n)) + ['jim', 'pam', 'dwight', 'dynamic-dave', 'api-ashley', 'role-rick'].map(n => c.users.db.delete(n)) ); }) ); From 6733882629108c2ca04d99e2d7f88fc4cd3501ce Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Tue, 1 Apr 2025 13:54:37 +0200 Subject: [PATCH 09/23] chore: lint and format --- src/users/integration.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/users/integration.test.ts b/src/users/integration.test.ts index 453d97dc..493de984 100644 --- a/src/users/integration.test.ts +++ b/src/users/integration.test.ts @@ -133,13 +133,13 @@ requireAtLeast( ); await admin.users[kind].revokeRoles('test', 'role-rick'); - expect(admin.users[kind].getAssignedRoles('role-rick')).resolves.toEqual({}) + expect(admin.users[kind].getAssignedRoles('role-rick')).resolves.toEqual({}); }); afterAll(() => makeClient('admin-key').then(async (c) => { await Promise.all( - ['jim', 'pam', 'dwight', 'dynamic-dave', 'api-ashley', 'role-rick'].map(n => c.users.db.delete(n)) + ['jim', 'pam', 'dwight', 'dynamic-dave', 'api-ashley', 'role-rick'].map((n) => c.users.db.delete(n)) ); }) ); From df010a2e5cc2f6538d65b75d33d3be8debb63398 Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Tue, 1 Apr 2025 17:34:03 +0200 Subject: [PATCH 10/23] refactor: replace .reduce with .map where possible Rewrote other .reduce occurences in a more succinct manner --- src/roles/util.ts | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/src/roles/util.ts b/src/roles/util.ts index 53571437..5ccd427f 100644 --- a/src/roles/util.ts +++ b/src/roles/util.ts @@ -130,16 +130,16 @@ 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), @@ -151,19 +151,12 @@ export class Map { active: user.active, }); static dbUsers = (users: WeaviateDBUser[]): UserDB[] => - users.reduce((acc, user) => { - acc.push(Map.dbUser(user)); - return acc; - }, [] as UserDB[]); - + users.map(Map.dbUser); static assignedUsers = (users: WeaviateAssignedUser[]): UserAssignment[] => - users.reduce((acc, user) => { - acc.push({ - id: user.userId || '', - userType: user.userType, - }); - return acc; - }, [] as UserAssignment[]); + users.map((user) => ({ + id: user.userId || '', + userType: user.userType, + })); } class PermissionsMapping { From dffc2c128e163174863f4dd303aa2f754eb12f7d Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Tue, 1 Apr 2025 17:44:36 +0200 Subject: [PATCH 11/23] test: use valid tenant name --- src/roles/integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/roles/integration.test.ts b/src/roles/integration.test.ts index 1d1b24b2..2035ef05 100644 --- a/src/roles/integration.test.ts +++ b/src/roles/integration.test.ts @@ -325,7 +325,7 @@ requireAtLeast( it('retrieves assigned users with namespace', async () => { await client.roles.create('landlord', { collection: 'Buildings', - tenant: 'john doe', + tenant: 'john-doe', actions: ['create_tenants', 'delete_tenants'], }); From 29f3cfcfb5672ae307b2d730d237ddedbe840416 Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Tue, 1 Apr 2025 17:45:09 +0200 Subject: [PATCH 12/23] refactor: collect optional parameters in an object --- src/users/index.ts | 48 +++++++++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/src/users/index.ts b/src/users/index.ts index 5e2b9a35..f237b571 100644 --- a/src/users/index.ts +++ b/src/users/index.ts @@ -59,7 +59,7 @@ export interface DBUsers extends UsersBase { * @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, includePermissions?: boolean) => Promise>; + getAssignedRoles: (userId: string, opts?: GetAssingedRolesOptions) => Promise>; create: (userId: string) => Promise; delete: (userId: string) => Promise; @@ -70,6 +70,10 @@ export interface DBUsers extends UsersBase { listAll: () => Promise; } +type GetAssingedRolesOptions = { + includePermissions?: boolean; +} + /** Operations supported for namespaced 'oidc' users.*/ export interface OIDCUsers extends UsersBase { /** @@ -78,7 +82,7 @@ export interface OIDCUsers extends UsersBase { * @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, includePermissions?: boolean) => Promise>; + getAssignedRoles: (userId: string, opts?: GetAssingedRolesOptions) => Promise>; } const users = (connection: ConnectionREST): Users => { @@ -104,10 +108,10 @@ const db = (connection: ConnectionREST): DBUsers => { type APIKeyResponse = { apikey: string }; return { - getAssignedRoles: (userId: string, includePermissions?: boolean) => - ns.getAssignedRoles('db', userId, includePermissions), - assignRoles: (roleNames: string | string[], userId: string) => ns.assignRoles(roleNames, userId, 'db'), - revokeRoles: (roleNames: string | string[], userId: string) => ns.revokeRoles(roleNames, userId, 'db'), + getAssignedRoles: (userId: string, opts?: GetAssingedRolesOptions) => + 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.postNoBody(`/users/db/${userId}`).then((resp) => resp.apikey), @@ -136,22 +140,19 @@ const db = (connection: ConnectionREST): DBUsers => { const oidc = (connection: ConnectionREST): OIDCUsers => { const ns = namespacedUsers(connection); return { - getAssignedRoles: (userId: string, includePermissions?: boolean) => - ns.getAssignedRoles('oidc', userId, includePermissions), - assignRoles: (roleNames: string | string[], userId: string) => ns.assignRoles(roleNames, userId, 'oidc'), - revokeRoles: (roleNames: string | string[], userId: string) => ns.revokeRoles(roleNames, userId, 'oidc'), + getAssignedRoles: (userId: string, opts?: GetAssingedRolesOptions) => + 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, - includePermissions?: boolean + getAssignedRoles: (userType: UserTypeInternal, userId: string, opts?: GetAssingedRolesOptions ) => Promise>; - assignRoles: (roleNames: string | string[], userId: string, userType?: UserTypeInternal) => Promise; - revokeRoles: (roleNames: string | string[], userId: string, userType?: UserTypeInternal) => Promise; + assignRoles: (roleNames: string | string[], userId: string, opts?: AssignRevokeOptions) => Promise; + revokeRoles: (roleNames: string | string[], userId: string, opts?: AssignRevokeOptions) => Promise; } const baseUsers = (connection: ConnectionREST): UsersBase => { @@ -162,23 +163,26 @@ const baseUsers = (connection: ConnectionREST): UsersBase => { }; }; +/** Optional arguments to /assign and /revoke endpoints. */ +type AssignRevokeOptions = { userType?: UserTypeInternal } + const namespacedUsers = (connection: ConnectionREST): NamespacedUsers => { return { - getAssignedRoles: (userType: UserTypeInternal, userId: string, includePermissions?: boolean) => + getAssignedRoles: (userType: UserTypeInternal, userId: string, opts?: GetAssingedRolesOptions) => connection .get( - `/authz/users/${userId}/roles/${userType}${includePermissions ? '?&includeFullRoles=true' : ''}` + `/authz/users/${userId}/roles/${userType}${opts?.includePermissions ? '?&includeFullRoles=true' : ''}` ) .then(Map.roles), - assignRoles: (roleNames: string | string[], userId: string, userType?: UserTypeInternal) => + assignRoles: (roleNames: string | string[], userId: string, opts?: AssignRevokeOptions) => connection.postEmpty(`/authz/users/${userId}/assign`, { + ...opts, roles: Array.isArray(roleNames) ? roleNames : [roleNames], - userType: userType, }), - revokeRoles: (roleNames: string | string[], userId: string, userType?: UserTypeInternal) => + revokeRoles: (roleNames: string | string[], userId: string, opts?: AssignRevokeOptions) => connection.postEmpty(`/authz/users/${userId}/revoke`, { + ...opts, roles: Array.isArray(roleNames) ? roleNames : [roleNames], - userType: userType, }), }; }; From d32ac420b54e7203db676fab8f726379cbd46c34 Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Tue, 1 Apr 2025 18:22:17 +0200 Subject: [PATCH 13/23] test: add test case w/ includePermissions=true --- src/users/integration.test.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/users/integration.test.ts b/src/users/integration.test.ts index 493de984..21a8ab05 100644 --- a/src/users/integration.test.ts +++ b/src/users/integration.test.ts @@ -1,4 +1,4 @@ -import weaviate, { ApiKey } from '..'; +import weaviate, { ApiKey, Role } from '..'; import { requireAtLeast } from '../../test/version.js'; import { WeaviateUserTypeDB } from '../v2'; import { UserDB } from './types.js'; @@ -136,10 +136,29 @@ requireAtLeast( 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'].map((n) => c.users.db.delete(n)) + ['jim', 'pam', 'dwight', 'dynamic-dave', 'api-ashley', 'role-rick', 'permission-peter'].map((n) => c.users.db.delete(n)) ); }) ); From 5bc5b08f8c19d2a1d09a985f70e17045bcfbe515 Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Tue, 1 Apr 2025 18:24:20 +0200 Subject: [PATCH 14/23] chore: lint and format --- src/roles/util.ts | 25 +++++++++++++++---------- src/users/index.ts | 25 +++++++++++++++++-------- src/users/integration.test.ts | 13 +++++++------ 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/src/roles/util.ts b/src/roles/util.ts index 5ccd427f..09f82bb0 100644 --- a/src/roles/util.ts +++ b/src/roles/util.ts @@ -130,16 +130,22 @@ 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), - }), {} 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 }, - }), {} 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), @@ -150,8 +156,7 @@ export class Map { roleNames: user.roles, active: user.active, }); - static dbUsers = (users: WeaviateDBUser[]): UserDB[] => - users.map(Map.dbUser); + static dbUsers = (users: WeaviateDBUser[]): UserDB[] => users.map(Map.dbUser); static assignedUsers = (users: WeaviateAssignedUser[]): UserAssignment[] => users.map((user) => ({ id: user.userId || '', diff --git a/src/users/index.ts b/src/users/index.ts index f237b571..27f65fce 100644 --- a/src/users/index.ts +++ b/src/users/index.ts @@ -72,7 +72,7 @@ export interface DBUsers extends UsersBase { type GetAssingedRolesOptions = { includePermissions?: boolean; -} +}; /** Operations supported for namespaced 'oidc' users.*/ export interface OIDCUsers extends UsersBase { @@ -110,8 +110,10 @@ const db = (connection: ConnectionREST): DBUsers => { return { getAssignedRoles: (userId: string, opts?: GetAssingedRolesOptions) => 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' }), + 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.postNoBody(`/users/db/${userId}`).then((resp) => resp.apikey), @@ -142,14 +144,19 @@ const oidc = (connection: ConnectionREST): OIDCUsers => { return { getAssignedRoles: (userId: string, opts?: GetAssingedRolesOptions) => 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' }), + 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?: GetAssingedRolesOptions + getAssignedRoles: ( + userType: UserTypeInternal, + userId: string, + opts?: GetAssingedRolesOptions ) => Promise>; assignRoles: (roleNames: string | string[], userId: string, opts?: AssignRevokeOptions) => Promise; revokeRoles: (roleNames: string | string[], userId: string, opts?: AssignRevokeOptions) => Promise; @@ -164,14 +171,16 @@ const baseUsers = (connection: ConnectionREST): UsersBase => { }; /** Optional arguments to /assign and /revoke endpoints. */ -type AssignRevokeOptions = { userType?: UserTypeInternal } +type AssignRevokeOptions = { userType?: UserTypeInternal }; const namespacedUsers = (connection: ConnectionREST): NamespacedUsers => { return { getAssignedRoles: (userType: UserTypeInternal, userId: string, opts?: GetAssingedRolesOptions) => connection .get( - `/authz/users/${userId}/roles/${userType}${opts?.includePermissions ? '?&includeFullRoles=true' : ''}` + `/authz/users/${userId}/roles/${userType}${ + opts?.includePermissions ? '?&includeFullRoles=true' : '' + }` ) .then(Map.roles), assignRoles: (roleNames: string | string[], userId: string, opts?: AssignRevokeOptions) => diff --git a/src/users/integration.test.ts b/src/users/integration.test.ts index 21a8ab05..8abb1d88 100644 --- a/src/users/integration.test.ts +++ b/src/users/integration.test.ts @@ -1,4 +1,4 @@ -import weaviate, { ApiKey, Role } from '..'; +import weaviate, { ApiKey } from '..'; import { requireAtLeast } from '../../test/version.js'; import { WeaviateUserTypeDB } from '../v2'; import { UserDB } from './types.js'; @@ -149,16 +149,17 @@ requireAtLeast( 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); - + 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)) + ['jim', 'pam', 'dwight', 'dynamic-dave', 'api-ashley', 'role-rick', 'permission-peter'].map((n) => + c.users.db.delete(n) + ) ); }) ); From 53049e33ade0b0209176db5eca57f721e52cda37 Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Wed, 2 Apr 2025 16:50:43 +0200 Subject: [PATCH 15/23] fix: expect no response for /activate and /deactivate --- src/users/index.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/users/index.ts b/src/users/index.ts index 27f65fce..2f99a55b 100644 --- a/src/users/index.ts +++ b/src/users/index.ts @@ -126,12 +126,12 @@ const db = (connection: ConnectionREST): DBUsers => { connection.postNoBody(`/users/db/${userId}/rotate-key`).then((resp) => resp.apikey), activate: (userId: string) => connection - .postNoBody(`/users/db/${userId}/activate`) + .postEmpty(`/users/db/${userId}/activate`, null) .then(() => true) .catch(allowCode(409)), deactivate: (userId: string) => connection - .postNoBody(`/users/db/${userId}/deactivate`) + .postEmpty(`/users/db/${userId}/deactivate`, null) .then(() => true) .catch(allowCode(409)), byName: (userId: string) => connection.get(`/users/db/${userId}`, true).then(Map.dbUser), @@ -178,8 +178,7 @@ const namespacedUsers = (connection: ConnectionREST): NamespacedUsers => { getAssignedRoles: (userType: UserTypeInternal, userId: string, opts?: GetAssingedRolesOptions) => connection .get( - `/authz/users/${userId}/roles/${userType}${ - opts?.includePermissions ? '?&includeFullRoles=true' : '' + `/authz/users/${userId}/roles/${userType}${opts?.includePermissions ? '?&includeFullRoles=true' : '' }` ) .then(Map.roles), From c0ca1eff283dd9f4c31936e695ae2379f25b3ab6 Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Wed, 2 Apr 2025 18:12:50 +0200 Subject: [PATCH 16/23] fix: allow expected code only for instances of WeaviateUnexpectedStatusCodeError --- src/users/index.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/users/index.ts b/src/users/index.ts index 2f99a55b..cd9c9f0a 100644 --- a/src/users/index.ts +++ b/src/users/index.ts @@ -1,3 +1,4 @@ +import { WeaviateUnexpectedStatusCodeError } from '../errors.js'; import { ConnectionREST } from '../index.js'; import { WeaviateUserTypeInternal as UserTypeInternal, @@ -102,8 +103,14 @@ const users = (connection: ConnectionREST): Users => { const db = (connection: ConnectionREST): DBUsers => { const ns = namespacedUsers(connection); - const allowCode = (code: number): ((reason: any) => boolean) => { - return (reason) => reason.code !== undefined && reason.code === code; + /** 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 }; @@ -128,12 +135,12 @@ const db = (connection: ConnectionREST): DBUsers => { connection .postEmpty(`/users/db/${userId}/activate`, null) .then(() => true) - .catch(allowCode(409)), + .catch(expectCode(409)), deactivate: (userId: string) => connection .postEmpty(`/users/db/${userId}/deactivate`, null) .then(() => true) - .catch(allowCode(409)), + .catch(expectCode(409)), byName: (userId: string) => connection.get(`/users/db/${userId}`, true).then(Map.dbUser), listAll: () => connection.get('/users/db', true).then(Map.dbUsers), }; From 28c3897fcae33aeb6ff77b2fa157dd49e80042dc Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Wed, 2 Apr 2025 18:16:23 +0200 Subject: [PATCH 17/23] refactor: declare types in **/types.ts --- src/users/index.ts | 21 +++++++-------------- src/users/types.ts | 11 ++++++++++- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/users/index.ts b/src/users/index.ts index cd9c9f0a..ae1725a1 100644 --- a/src/users/index.ts +++ b/src/users/index.ts @@ -8,7 +8,7 @@ import { } from '../openapi/types.js'; import { Role } from '../roles/types.js'; import { Map } from '../roles/util.js'; -import { User, UserDB } from './types.js'; +import { AssignRevokeOptions, GetAssignedRolesOptions, User, UserDB } from './types.js'; /** * Operations supported for 'db', 'oidc', and legacy (non-namespaced) users. @@ -60,7 +60,7 @@ export interface DBUsers extends UsersBase { * @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?: GetAssingedRolesOptions) => Promise>; + getAssignedRoles: (userId: string, opts?: GetAssignedRolesOptions) => Promise>; create: (userId: string) => Promise; delete: (userId: string) => Promise; @@ -71,10 +71,6 @@ export interface DBUsers extends UsersBase { listAll: () => Promise; } -type GetAssingedRolesOptions = { - includePermissions?: boolean; -}; - /** Operations supported for namespaced 'oidc' users.*/ export interface OIDCUsers extends UsersBase { /** @@ -83,7 +79,7 @@ export interface OIDCUsers extends UsersBase { * @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?: GetAssingedRolesOptions) => Promise>; + getAssignedRoles: (userId: string, opts?: GetAssignedRolesOptions) => Promise>; } const users = (connection: ConnectionREST): Users => { @@ -115,7 +111,7 @@ const db = (connection: ConnectionREST): DBUsers => { type APIKeyResponse = { apikey: string }; return { - getAssignedRoles: (userId: string, opts?: GetAssingedRolesOptions) => + getAssignedRoles: (userId: string, opts?: GetAssignedRolesOptions) => ns.getAssignedRoles('db', userId, opts), assignRoles: (roleNames: string | string[], userId: string) => ns.assignRoles(roleNames, userId, { userType: 'db' }), @@ -149,7 +145,7 @@ const db = (connection: ConnectionREST): DBUsers => { const oidc = (connection: ConnectionREST): OIDCUsers => { const ns = namespacedUsers(connection); return { - getAssignedRoles: (userId: string, opts?: GetAssingedRolesOptions) => + getAssignedRoles: (userId: string, opts?: GetAssignedRolesOptions) => ns.getAssignedRoles('oidc', userId, opts), assignRoles: (roleNames: string | string[], userId: string) => ns.assignRoles(roleNames, userId, { userType: 'oidc' }), @@ -163,7 +159,7 @@ interface NamespacedUsers { getAssignedRoles: ( userType: UserTypeInternal, userId: string, - opts?: GetAssingedRolesOptions + opts?: GetAssignedRolesOptions ) => Promise>; assignRoles: (roleNames: string | string[], userId: string, opts?: AssignRevokeOptions) => Promise; revokeRoles: (roleNames: string | string[], userId: string, opts?: AssignRevokeOptions) => Promise; @@ -177,12 +173,9 @@ const baseUsers = (connection: ConnectionREST): UsersBase => { }; }; -/** Optional arguments to /assign and /revoke endpoints. */ -type AssignRevokeOptions = { userType?: UserTypeInternal }; - const namespacedUsers = (connection: ConnectionREST): NamespacedUsers => { return { - getAssignedRoles: (userType: UserTypeInternal, userId: string, opts?: GetAssingedRolesOptions) => + getAssignedRoles: (userType: UserTypeInternal, userId: string, opts?: GetAssignedRolesOptions) => connection .get( `/authz/users/${userId}/roles/${userType}${opts?.includePermissions ? '?&includeFullRoles=true' : '' diff --git a/src/users/types.ts b/src/users/types.ts index 01f4c85b..0d54ef6f 100644 --- a/src/users/types.ts +++ b/src/users/types.ts @@ -1,4 +1,4 @@ -import { WeaviateUserTypeDB as UserTypeDB } from '../openapi/types.js'; +import { WeaviateUserTypeDB as UserTypeDB, WeaviateUserTypeInternal } from '../openapi/types.js'; import { Role } from '../roles/types.js'; export type User = { @@ -12,3 +12,12 @@ export type UserDB = { 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 }; + From 84aac0d21c91c2afdd1bd7d2bfe9200856795535 Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Wed, 2 Apr 2025 18:21:37 +0200 Subject: [PATCH 18/23] test: await on all expectations which should resolve/reject --- src/roles/integration.test.ts | 1 - src/users/index.ts | 5 +++-- src/users/integration.test.ts | 14 +++++++------- src/users/types.ts | 1 - 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/roles/integration.test.ts b/src/roles/integration.test.ts index 2035ef05..1d335c9f 100644 --- a/src/roles/integration.test.ts +++ b/src/roles/integration.test.ts @@ -335,7 +335,6 @@ requireAtLeast( 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' }), diff --git a/src/users/index.ts b/src/users/index.ts index ae1725a1..29e0476c 100644 --- a/src/users/index.ts +++ b/src/users/index.ts @@ -106,7 +106,7 @@ const db = (connection: ConnectionREST): DBUsers => { return error.code === code; } throw error; - } + }; }; type APIKeyResponse = { apikey: string }; @@ -178,7 +178,8 @@ const namespacedUsers = (connection: ConnectionREST): NamespacedUsers => { getAssignedRoles: (userType: UserTypeInternal, userId: string, opts?: GetAssignedRolesOptions) => connection .get( - `/authz/users/${userId}/roles/${userType}${opts?.includePermissions ? '?&includeFullRoles=true' : '' + `/authz/users/${userId}/roles/${userType}${ + opts?.includePermissions ? '?&includeFullRoles=true' : '' }` ) .then(Map.roles), diff --git a/src/users/integration.test.ts b/src/users/integration.test.ts index 8abb1d88..91f7b453 100644 --- a/src/users/integration.test.ts +++ b/src/users/integration.test.ts @@ -76,19 +76,19 @@ requireAtLeast( }; await client.users.db.create('dynamic-dave'); - expectDave().toHaveProperty('active', true); + await expectDave().toHaveProperty('active', true); // Second activation is a no-op - expect(client.users.db.activate('dynamic-dave')).resolves.toEqual(true); + await expect(client.users.db.activate('dynamic-dave')).resolves.toEqual(true); await client.users.db.deactivate('dynamic-dave'); - expectDave().toHaveProperty('active', false); + await expectDave().toHaveProperty('active', false); // Second deactivation is a no-op - expect(client.users.db.deactivate('dynamic-dave')).resolves.toEqual(true); + await expect(client.users.db.deactivate('dynamic-dave')).resolves.toEqual(true); await client.users.db.delete('dynamic-dave'); - expectDave(false).toHaveProperty('code', 404); + await expectDave(false).toHaveProperty('code', 404); }); it('should be able to obtain and rotate api keys', async () => { @@ -128,12 +128,12 @@ requireAtLeast( } await admin.users[kind].assignRoles('test', 'role-rick'); - expect(admin.users[kind].getAssignedRoles('role-rick')).resolves.toEqual( + await expect(admin.users[kind].getAssignedRoles('role-rick')).resolves.toEqual( expect.objectContaining({ test: expect.any(Object) }) ); await admin.users[kind].revokeRoles('test', 'role-rick'); - expect(admin.users[kind].getAssignedRoles('role-rick')).resolves.toEqual({}); + await expect(admin.users[kind].getAssignedRoles('role-rick')).resolves.toEqual({}); }); it('should be able to fetch assigned roles with all permissions', async () => { diff --git a/src/users/types.ts b/src/users/types.ts index 0d54ef6f..6dee0ad7 100644 --- a/src/users/types.ts +++ b/src/users/types.ts @@ -20,4 +20,3 @@ export type GetAssignedRolesOptions = { /** Optional arguments to /assign and /revoke endpoints. */ export type AssignRevokeOptions = { userType?: WeaviateUserTypeInternal }; - From 2e711436970f7593554fcf933177a3d155f4376f Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Wed, 2 Apr 2025 18:24:55 +0200 Subject: [PATCH 19/23] refactor: replace postNoBody with a more explicit postReturn --- src/connection/http.ts | 4 ---- src/users/index.ts | 6 ++++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/connection/http.ts b/src/connection/http.ts index 621a3f3b..1af076fd 100644 --- a/src/connection/http.ts +++ b/src/connection/http.ts @@ -120,10 +120,6 @@ export default class ConnectionREST { return this.http.post(path, payload, true, '') as Promise; }; - postNoBody = (path: string): Promise => { - return this.postReturn(path, null); - }; - postEmpty = (path: string, payload: B): Promise => { if (this.authEnabled) { return this.login().then((token) => this.http.post(path, payload, false, token)); diff --git a/src/users/index.ts b/src/users/index.ts index 29e0476c..0237ac96 100644 --- a/src/users/index.ts +++ b/src/users/index.ts @@ -119,14 +119,16 @@ const db = (connection: ConnectionREST): DBUsers => { ns.revokeRoles(roleNames, userId, { userType: 'db' }), create: (userId: string) => - connection.postNoBody(`/users/db/${userId}`).then((resp) => resp.apikey), + 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.postNoBody(`/users/db/${userId}/rotate-key`).then((resp) => resp.apikey), + connection + .postReturn(`/users/db/${userId}/rotate-key`, null) + .then((resp) => resp.apikey), activate: (userId: string) => connection .postEmpty(`/users/db/${userId}/activate`, null) From bf294e99d51b66a6aa3649b0874c4bf861a6f9a7 Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Wed, 2 Apr 2025 18:57:46 +0200 Subject: [PATCH 20/23] chore: add documentation for dynamic user management --- src/roles/index.ts | 3 ++- src/users/index.ts | 57 ++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/roles/index.ts b/src/roles/index.ts index 139e9dee..6971cf4f 100644 --- a/src/roles/index.ts +++ b/src/roles/index.ts @@ -35,7 +35,8 @@ 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. diff --git a/src/users/index.ts b/src/users/index.ts index 0237ac96..037a3a34 100644 --- a/src/users/index.ts +++ b/src/users/index.ts @@ -55,26 +55,74 @@ export interface Users extends UsersBase { /** Operations supported for namespaced 'db' users.*/ export interface DBUsers extends UsersBase { /** - * Retrieve the roles assigned to a user. + * Retrieve the roles assigned to a 'db_user' 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>; + /** 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; + + /** + * Delete a 'db_user' user. It is not possible to delete 'db_env_user' users programmatically. + * + * @param {string} userId The ID of the user to delete. + * @returns {Promise} `true` if the user has been successfully deleted. + */ 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) => 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 a user. + * 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. @@ -167,6 +215,7 @@ interface NamespacedUsers { 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 { @@ -175,13 +224,13 @@ const baseUsers = (connection: ConnectionREST): UsersBase => { }; }; +/** 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' : '' + `/authz/users/${userId}/roles/${userType}${opts?.includePermissions ? '?&includeFullRoles=true' : '' }` ) .then(Map.roles), From 67b675e935f72f851c1d030f8fd0475992d85a83 Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Wed, 2 Apr 2025 19:20:26 +0200 Subject: [PATCH 21/23] chore: lint and format --- src/users/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/users/index.ts b/src/users/index.ts index 037a3a34..2fa51d51 100644 --- a/src/users/index.ts +++ b/src/users/index.ts @@ -230,7 +230,8 @@ const namespacedUsers = (connection: ConnectionREST): NamespacedUsers => { getAssignedRoles: (userType: UserTypeInternal, userId: string, opts?: GetAssignedRolesOptions) => connection .get( - `/authz/users/${userId}/roles/${userType}${opts?.includePermissions ? '?&includeFullRoles=true' : '' + `/authz/users/${userId}/roles/${userType}${ + opts?.includePermissions ? '?&includeFullRoles=true' : '' }` ) .then(Map.roles), From aa04c920ef9840da0c62226a1fc2373030efe5e3 Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Thu, 3 Apr 2025 15:04:16 +0200 Subject: [PATCH 22/23] test: use different port binding for backup/unit.test.ts Seems like Node 18 does not automatically 'resolve' port collisions, so 2 test running Express servers on the same ports will interfere with one another. --- src/collections/backup/unit.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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); }); From a140c534dbda0f8b5b43109bcf12513b46cef5bb Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Thu, 3 Apr 2025 15:17:57 +0200 Subject: [PATCH 23/23] feat: add revokeKey option to /deactivate --- src/users/index.ts | 8 ++++---- src/users/integration.test.ts | 2 +- src/users/types.ts | 3 +++ 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/users/index.ts b/src/users/index.ts index 2fa51d51..7b2c608a 100644 --- a/src/users/index.ts +++ b/src/users/index.ts @@ -8,7 +8,7 @@ import { } from '../openapi/types.js'; import { Role } from '../roles/types.js'; import { Map } from '../roles/util.js'; -import { AssignRevokeOptions, GetAssignedRolesOptions, User, UserDB } from './types.js'; +import { AssignRevokeOptions, DeactivateOptions, GetAssignedRolesOptions, User, UserDB } from './types.js'; /** * Operations supported for 'db', 'oidc', and legacy (non-namespaced) users. @@ -101,7 +101,7 @@ export interface DBUsers extends UsersBase { * @param {string} userId The ID of the user to deactivate. * @returns {Promise} `true` if the user has been successfully deactivated. */ - deactivate: (userId: string) => Promise; + deactivate: (userId: string, opts?: DeactivateOptions) => Promise; /** * Retrieve information about the 'db_user' / 'db_env_user' user. @@ -182,9 +182,9 @@ const db = (connection: ConnectionREST): DBUsers => { .postEmpty(`/users/db/${userId}/activate`, null) .then(() => true) .catch(expectCode(409)), - deactivate: (userId: string) => + deactivate: (userId: string, opts?: DeactivateOptions) => connection - .postEmpty(`/users/db/${userId}/deactivate`, null) + .postEmpty(`/users/db/${userId}/deactivate`, opts || null) .then(() => true) .catch(expectCode(409)), byName: (userId: string) => connection.get(`/users/db/${userId}`, true).then(Map.dbUser), diff --git a/src/users/integration.test.ts b/src/users/integration.test.ts index 91f7b453..0c442bfe 100644 --- a/src/users/integration.test.ts +++ b/src/users/integration.test.ts @@ -85,7 +85,7 @@ requireAtLeast( await expectDave().toHaveProperty('active', false); // Second deactivation is a no-op - await expect(client.users.db.deactivate('dynamic-dave')).resolves.toEqual(true); + 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); diff --git a/src/users/types.ts b/src/users/types.ts index 6dee0ad7..b4a9d59d 100644 --- a/src/users/types.ts +++ b/src/users/types.ts @@ -20,3 +20,6 @@ export type GetAssignedRolesOptions = { /** Optional arguments to /assign and /revoke endpoints. */ export type AssignRevokeOptions = { userType?: WeaviateUserTypeInternal }; + +/** Optional arguments to /deactivate endpoint. */ +export type DeactivateOptions = { revokeKey?: boolean };