Merge pull request #10022 from Budibase/feature/scim
Feature - SCIM endpoints
This commit is contained in:
commit
e1669c8260
|
@ -5,6 +5,8 @@ import {
|
||||||
GoogleInnerConfig,
|
GoogleInnerConfig,
|
||||||
OIDCConfig,
|
OIDCConfig,
|
||||||
OIDCInnerConfig,
|
OIDCInnerConfig,
|
||||||
|
SCIMConfig,
|
||||||
|
SCIMInnerConfig,
|
||||||
SettingsConfig,
|
SettingsConfig,
|
||||||
SettingsInnerConfig,
|
SettingsInnerConfig,
|
||||||
SMTPConfig,
|
SMTPConfig,
|
||||||
|
@ -241,3 +243,10 @@ export async function getSMTPConfig(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SCIM
|
||||||
|
|
||||||
|
export async function getSCIMConfig(): Promise<SCIMInnerConfig | undefined> {
|
||||||
|
const config = await getConfig<SCIMConfig>(ConfigType.SCIM)
|
||||||
|
return config?.config
|
||||||
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ export enum Header {
|
||||||
TOKEN = "x-budibase-token",
|
TOKEN = "x-budibase-token",
|
||||||
CSRF_TOKEN = "x-csrf-token",
|
CSRF_TOKEN = "x-csrf-token",
|
||||||
CORRELATION_ID = "x-budibase-correlation-id",
|
CORRELATION_ID = "x-budibase-correlation-id",
|
||||||
|
AUTHORIZATION = "authorization",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum GlobalRole {
|
export enum GlobalRole {
|
||||||
|
@ -38,6 +39,7 @@ export enum Config {
|
||||||
GOOGLE = "google",
|
GOOGLE = "google",
|
||||||
OIDC = "oidc",
|
OIDC = "oidc",
|
||||||
OIDC_LOGOS = "logos_oidc",
|
OIDC_LOGOS = "logos_oidc",
|
||||||
|
SCIM = "scim",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MIN_VALID_DATE = new Date(-2147483647000)
|
export const MIN_VALID_DATE = new Date(-2147483647000)
|
||||||
|
|
|
@ -214,6 +214,13 @@ export function doInEnvironmentContext(
|
||||||
return newContext(updates, task)
|
return newContext(updates, task)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function doInScimContext(task: any) {
|
||||||
|
const updates: ContextMap = {
|
||||||
|
isScim: true,
|
||||||
|
}
|
||||||
|
return newContext(updates, task)
|
||||||
|
}
|
||||||
|
|
||||||
export function getEnvironmentVariables() {
|
export function getEnvironmentVariables() {
|
||||||
const context = Context.get()
|
const context = Context.get()
|
||||||
if (!context.environmentVariables) {
|
if (!context.environmentVariables) {
|
||||||
|
@ -270,3 +277,9 @@ export function getDevAppDB(opts?: any): Database {
|
||||||
}
|
}
|
||||||
return getDB(conversions.getDevelopmentAppID(appId), opts)
|
return getDB(conversions.getDevelopmentAppID(appId), opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isScim(): boolean {
|
||||||
|
const context = Context.get()
|
||||||
|
const scimCall = context?.isScim
|
||||||
|
return !!scimCall
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { testEnv } from "../../../tests"
|
import { testEnv } from "../../../tests"
|
||||||
const context = require("../")
|
import * as context from "../"
|
||||||
const { DEFAULT_TENANT_ID } = require("../../constants")
|
import { DEFAULT_TENANT_ID } from "../../constants"
|
||||||
|
|
||||||
describe("context", () => {
|
describe("context", () => {
|
||||||
describe("doInTenant", () => {
|
describe("doInTenant", () => {
|
||||||
|
@ -131,4 +131,17 @@ describe("context", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("doInScimContext", () => {
|
||||||
|
it("returns true when set", () => {
|
||||||
|
context.doInScimContext(() => {
|
||||||
|
const isScim = context.isScim()
|
||||||
|
expect(isScim).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it("returns false when not set", () => {
|
||||||
|
const isScim = context.isScim()
|
||||||
|
expect(isScim).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -6,4 +6,5 @@ export type ContextMap = {
|
||||||
appId?: string
|
appId?: string
|
||||||
identity?: IdentityContext
|
identity?: IdentityContext
|
||||||
environmentVariables?: Record<string, string>
|
environmentVariables?: Record<string, string>
|
||||||
|
isScim?: boolean
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,3 +8,4 @@ export { default as Replication } from "./Replication"
|
||||||
export * from "../constants/db"
|
export * from "../constants/db"
|
||||||
export { getGlobalDBName, baseGlobalDBName } from "../context"
|
export { getGlobalDBName, baseGlobalDBName } from "../context"
|
||||||
export * from "./lucene"
|
export * from "./lucene"
|
||||||
|
export * as searchIndexes from "./searchIndexes"
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
import fetch from "node-fetch"
|
import fetch from "node-fetch"
|
||||||
import { getCouchInfo } from "./couch"
|
import { getCouchInfo } from "./couch"
|
||||||
import { SearchFilters, Row } from "@budibase/types"
|
import { SearchFilters, Row } from "@budibase/types"
|
||||||
|
import { createUserIndex } from "./searchIndexes/searchIndexes"
|
||||||
|
|
||||||
const QUERY_START_REGEX = /\d[0-9]*:/g
|
const QUERY_START_REGEX = /\d[0-9]*:/g
|
||||||
|
|
||||||
interface SearchResponse<T> {
|
interface SearchResponse<T> {
|
||||||
rows: T[] | any[]
|
rows: T[] | any[]
|
||||||
bookmark: string
|
bookmark?: string
|
||||||
|
totalRows: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaginatedSearchResponse<T> extends SearchResponse<T> {
|
interface PaginatedSearchResponse<T> extends SearchResponse<T> {
|
||||||
|
@ -42,23 +44,26 @@ export function removeKeyNumbering(key: any): string {
|
||||||
* Optionally takes a base lucene query object.
|
* Optionally takes a base lucene query object.
|
||||||
*/
|
*/
|
||||||
export class QueryBuilder<T> {
|
export class QueryBuilder<T> {
|
||||||
dbName: string
|
#dbName: string
|
||||||
index: string
|
#index: string
|
||||||
query: SearchFilters
|
#query: SearchFilters
|
||||||
limit: number
|
#limit: number
|
||||||
sort?: string
|
#sort?: string
|
||||||
bookmark?: string
|
#bookmark?: string
|
||||||
sortOrder: string
|
#sortOrder: string
|
||||||
sortType: string
|
#sortType: string
|
||||||
includeDocs: boolean
|
#includeDocs: boolean
|
||||||
version?: string
|
#version?: string
|
||||||
indexBuilder?: () => Promise<any>
|
#indexBuilder?: () => Promise<any>
|
||||||
noEscaping = false
|
#noEscaping = false
|
||||||
|
#skip?: number
|
||||||
|
|
||||||
|
static readonly maxLimit = 200
|
||||||
|
|
||||||
constructor(dbName: string, index: string, base?: SearchFilters) {
|
constructor(dbName: string, index: string, base?: SearchFilters) {
|
||||||
this.dbName = dbName
|
this.#dbName = dbName
|
||||||
this.index = index
|
this.#index = index
|
||||||
this.query = {
|
this.#query = {
|
||||||
allOr: false,
|
allOr: false,
|
||||||
string: {},
|
string: {},
|
||||||
fuzzy: {},
|
fuzzy: {},
|
||||||
|
@ -73,86 +78,96 @@ export class QueryBuilder<T> {
|
||||||
containsAny: {},
|
containsAny: {},
|
||||||
...base,
|
...base,
|
||||||
}
|
}
|
||||||
this.limit = 50
|
this.#limit = 50
|
||||||
this.sortOrder = "ascending"
|
this.#sortOrder = "ascending"
|
||||||
this.sortType = "string"
|
this.#sortType = "string"
|
||||||
this.includeDocs = true
|
this.#includeDocs = true
|
||||||
}
|
}
|
||||||
|
|
||||||
disableEscaping() {
|
disableEscaping() {
|
||||||
this.noEscaping = true
|
this.#noEscaping = true
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setIndexBuilder(builderFn: () => Promise<any>) {
|
setIndexBuilder(builderFn: () => Promise<any>) {
|
||||||
this.indexBuilder = builderFn
|
this.#indexBuilder = builderFn
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setVersion(version?: string) {
|
setVersion(version?: string) {
|
||||||
if (version != null) {
|
if (version != null) {
|
||||||
this.version = version
|
this.#version = version
|
||||||
}
|
}
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setTable(tableId: string) {
|
setTable(tableId: string) {
|
||||||
this.query.equal!.tableId = tableId
|
this.#query.equal!.tableId = tableId
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setLimit(limit?: number) {
|
setLimit(limit?: number) {
|
||||||
if (limit != null) {
|
if (limit != null) {
|
||||||
this.limit = limit
|
this.#limit = limit
|
||||||
}
|
}
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setSort(sort?: string) {
|
setSort(sort?: string) {
|
||||||
if (sort != null) {
|
if (sort != null) {
|
||||||
this.sort = sort
|
this.#sort = sort
|
||||||
}
|
}
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setSortOrder(sortOrder?: string) {
|
setSortOrder(sortOrder?: string) {
|
||||||
if (sortOrder != null) {
|
if (sortOrder != null) {
|
||||||
this.sortOrder = sortOrder
|
this.#sortOrder = sortOrder
|
||||||
}
|
}
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setSortType(sortType?: string) {
|
setSortType(sortType?: string) {
|
||||||
if (sortType != null) {
|
if (sortType != null) {
|
||||||
this.sortType = sortType
|
this.#sortType = sortType
|
||||||
}
|
}
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setBookmark(bookmark?: string) {
|
setBookmark(bookmark?: string) {
|
||||||
if (bookmark != null) {
|
if (bookmark != null) {
|
||||||
this.bookmark = bookmark
|
this.#bookmark = bookmark
|
||||||
}
|
}
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSkip(skip: number | undefined) {
|
||||||
|
this.#skip = skip
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
excludeDocs() {
|
excludeDocs() {
|
||||||
this.includeDocs = false
|
this.#includeDocs = false
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
includeDocs() {
|
||||||
|
this.#includeDocs = true
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
addString(key: string, partial: string) {
|
addString(key: string, partial: string) {
|
||||||
this.query.string![key] = partial
|
this.#query.string![key] = partial
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
addFuzzy(key: string, fuzzy: string) {
|
addFuzzy(key: string, fuzzy: string) {
|
||||||
this.query.fuzzy![key] = fuzzy
|
this.#query.fuzzy![key] = fuzzy
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
addRange(key: string, low: string | number, high: string | number) {
|
addRange(key: string, low: string | number, high: string | number) {
|
||||||
this.query.range![key] = {
|
this.#query.range![key] = {
|
||||||
low,
|
low,
|
||||||
high,
|
high,
|
||||||
}
|
}
|
||||||
|
@ -160,51 +175,51 @@ export class QueryBuilder<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
addEqual(key: string, value: any) {
|
addEqual(key: string, value: any) {
|
||||||
this.query.equal![key] = value
|
this.#query.equal![key] = value
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
addNotEqual(key: string, value: any) {
|
addNotEqual(key: string, value: any) {
|
||||||
this.query.notEqual![key] = value
|
this.#query.notEqual![key] = value
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
addEmpty(key: string, value: any) {
|
addEmpty(key: string, value: any) {
|
||||||
this.query.empty![key] = value
|
this.#query.empty![key] = value
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
addNotEmpty(key: string, value: any) {
|
addNotEmpty(key: string, value: any) {
|
||||||
this.query.notEmpty![key] = value
|
this.#query.notEmpty![key] = value
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
addOneOf(key: string, value: any) {
|
addOneOf(key: string, value: any) {
|
||||||
this.query.oneOf![key] = value
|
this.#query.oneOf![key] = value
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
addContains(key: string, value: any) {
|
addContains(key: string, value: any) {
|
||||||
this.query.contains![key] = value
|
this.#query.contains![key] = value
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
addNotContains(key: string, value: any) {
|
addNotContains(key: string, value: any) {
|
||||||
this.query.notContains![key] = value
|
this.#query.notContains![key] = value
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
addContainsAny(key: string, value: any) {
|
addContainsAny(key: string, value: any) {
|
||||||
this.query.containsAny![key] = value
|
this.#query.containsAny![key] = value
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
setAllOr() {
|
setAllOr() {
|
||||||
this.query.allOr = true
|
this.#query.allOr = true
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSpaces(input: string) {
|
handleSpaces(input: string) {
|
||||||
if (this.noEscaping) {
|
if (this.#noEscaping) {
|
||||||
return input
|
return input
|
||||||
} else {
|
} else {
|
||||||
return input.replace(/ /g, "_")
|
return input.replace(/ /g, "_")
|
||||||
|
@ -219,7 +234,7 @@ export class QueryBuilder<T> {
|
||||||
* @returns {string|*}
|
* @returns {string|*}
|
||||||
*/
|
*/
|
||||||
preprocess(value: any, { escape, lowercase, wrap, type }: any = {}) {
|
preprocess(value: any, { escape, lowercase, wrap, type }: any = {}) {
|
||||||
const hasVersion = !!this.version
|
const hasVersion = !!this.#version
|
||||||
// Determine if type needs wrapped
|
// Determine if type needs wrapped
|
||||||
const originalType = typeof value
|
const originalType = typeof value
|
||||||
// Convert to lowercase
|
// Convert to lowercase
|
||||||
|
@ -227,7 +242,7 @@ export class QueryBuilder<T> {
|
||||||
value = value.toLowerCase ? value.toLowerCase() : value
|
value = value.toLowerCase ? value.toLowerCase() : value
|
||||||
}
|
}
|
||||||
// Escape characters
|
// Escape characters
|
||||||
if (!this.noEscaping && escape && originalType === "string") {
|
if (!this.#noEscaping && escape && originalType === "string") {
|
||||||
value = `${value}`.replace(/[ #+\-&|!(){}\]^"~*?:\\]/g, "\\$&")
|
value = `${value}`.replace(/[ #+\-&|!(){}\]^"~*?:\\]/g, "\\$&")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -242,7 +257,7 @@ export class QueryBuilder<T> {
|
||||||
|
|
||||||
isMultiCondition() {
|
isMultiCondition() {
|
||||||
let count = 0
|
let count = 0
|
||||||
for (let filters of Object.values(this.query)) {
|
for (let filters of Object.values(this.#query)) {
|
||||||
// not contains is one massive filter in allOr mode
|
// not contains is one massive filter in allOr mode
|
||||||
if (typeof filters === "object") {
|
if (typeof filters === "object") {
|
||||||
count += Object.keys(filters).length
|
count += Object.keys(filters).length
|
||||||
|
@ -272,13 +287,13 @@ export class QueryBuilder<T> {
|
||||||
|
|
||||||
buildSearchQuery() {
|
buildSearchQuery() {
|
||||||
const builder = this
|
const builder = this
|
||||||
let allOr = this.query && this.query.allOr
|
let allOr = this.#query && this.#query.allOr
|
||||||
let query = allOr ? "" : "*:*"
|
let query = allOr ? "" : "*:*"
|
||||||
const allPreProcessingOpts = { escape: true, lowercase: true, wrap: true }
|
const allPreProcessingOpts = { escape: true, lowercase: true, wrap: true }
|
||||||
let tableId
|
let tableId
|
||||||
if (this.query.equal!.tableId) {
|
if (this.#query.equal!.tableId) {
|
||||||
tableId = this.query.equal!.tableId
|
tableId = this.#query.equal!.tableId
|
||||||
delete this.query.equal!.tableId
|
delete this.#query.equal!.tableId
|
||||||
}
|
}
|
||||||
|
|
||||||
const equal = (key: string, value: any) => {
|
const equal = (key: string, value: any) => {
|
||||||
|
@ -363,8 +378,8 @@ export class QueryBuilder<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct the actual lucene search query string from JSON structure
|
// Construct the actual lucene search query string from JSON structure
|
||||||
if (this.query.string) {
|
if (this.#query.string) {
|
||||||
build(this.query.string, (key: string, value: any) => {
|
build(this.#query.string, (key: string, value: any) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
@ -376,8 +391,8 @@ export class QueryBuilder<T> {
|
||||||
return `${key}:${value}*`
|
return `${key}:${value}*`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.query.range) {
|
if (this.#query.range) {
|
||||||
build(this.query.range, (key: string, value: any) => {
|
build(this.#query.range, (key: string, value: any) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
@ -392,8 +407,8 @@ export class QueryBuilder<T> {
|
||||||
return `${key}:[${low} TO ${high}]`
|
return `${key}:[${low} TO ${high}]`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.query.fuzzy) {
|
if (this.#query.fuzzy) {
|
||||||
build(this.query.fuzzy, (key: string, value: any) => {
|
build(this.#query.fuzzy, (key: string, value: any) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
@ -405,34 +420,34 @@ export class QueryBuilder<T> {
|
||||||
return `${key}:${value}~`
|
return `${key}:${value}~`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.query.equal) {
|
if (this.#query.equal) {
|
||||||
build(this.query.equal, equal)
|
build(this.#query.equal, equal)
|
||||||
}
|
}
|
||||||
if (this.query.notEqual) {
|
if (this.#query.notEqual) {
|
||||||
build(this.query.notEqual, (key: string, value: any) => {
|
build(this.#query.notEqual, (key: string, value: any) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return `!${key}:${builder.preprocess(value, allPreProcessingOpts)}`
|
return `!${key}:${builder.preprocess(value, allPreProcessingOpts)}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.query.empty) {
|
if (this.#query.empty) {
|
||||||
build(this.query.empty, (key: string) => `!${key}:["" TO *]`)
|
build(this.#query.empty, (key: string) => `!${key}:["" TO *]`)
|
||||||
}
|
}
|
||||||
if (this.query.notEmpty) {
|
if (this.#query.notEmpty) {
|
||||||
build(this.query.notEmpty, (key: string) => `${key}:["" TO *]`)
|
build(this.#query.notEmpty, (key: string) => `${key}:["" TO *]`)
|
||||||
}
|
}
|
||||||
if (this.query.oneOf) {
|
if (this.#query.oneOf) {
|
||||||
build(this.query.oneOf, oneOf)
|
build(this.#query.oneOf, oneOf)
|
||||||
}
|
}
|
||||||
if (this.query.contains) {
|
if (this.#query.contains) {
|
||||||
build(this.query.contains, contains)
|
build(this.#query.contains, contains)
|
||||||
}
|
}
|
||||||
if (this.query.notContains) {
|
if (this.#query.notContains) {
|
||||||
build(this.compressFilters(this.query.notContains), notContains)
|
build(this.compressFilters(this.#query.notContains), notContains)
|
||||||
}
|
}
|
||||||
if (this.query.containsAny) {
|
if (this.#query.containsAny) {
|
||||||
build(this.query.containsAny, containsAny)
|
build(this.#query.containsAny, containsAny)
|
||||||
}
|
}
|
||||||
// make sure table ID is always added as an AND
|
// make sure table ID is always added as an AND
|
||||||
if (tableId) {
|
if (tableId) {
|
||||||
|
@ -446,29 +461,65 @@ export class QueryBuilder<T> {
|
||||||
buildSearchBody() {
|
buildSearchBody() {
|
||||||
let body: any = {
|
let body: any = {
|
||||||
q: this.buildSearchQuery(),
|
q: this.buildSearchQuery(),
|
||||||
limit: Math.min(this.limit, 200),
|
limit: Math.min(this.#limit, QueryBuilder.maxLimit),
|
||||||
include_docs: this.includeDocs,
|
include_docs: this.#includeDocs,
|
||||||
}
|
}
|
||||||
if (this.bookmark) {
|
if (this.#bookmark) {
|
||||||
body.bookmark = this.bookmark
|
body.bookmark = this.#bookmark
|
||||||
}
|
}
|
||||||
if (this.sort) {
|
if (this.#sort) {
|
||||||
const order = this.sortOrder === "descending" ? "-" : ""
|
const order = this.#sortOrder === "descending" ? "-" : ""
|
||||||
const type = `<${this.sortType}>`
|
const type = `<${this.#sortType}>`
|
||||||
body.sort = `${order}${this.handleSpaces(this.sort)}${type}`
|
body.sort = `${order}${this.handleSpaces(this.#sort)}${type}`
|
||||||
}
|
}
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
async run() {
|
async run() {
|
||||||
|
if (this.#skip) {
|
||||||
|
await this.#skipItems(this.#skip)
|
||||||
|
}
|
||||||
|
return await this.#execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lucene queries do not support pagination and use bookmarks instead.
|
||||||
|
* For the given builder, walk through pages using bookmarks until the desired
|
||||||
|
* page has been met.
|
||||||
|
*/
|
||||||
|
async #skipItems(skip: number) {
|
||||||
|
// Lucene does not support pagination.
|
||||||
|
// Handle pagination by finding the right bookmark
|
||||||
|
const prevIncludeDocs = this.#includeDocs
|
||||||
|
const prevLimit = this.#limit
|
||||||
|
|
||||||
|
this.excludeDocs()
|
||||||
|
let skipRemaining = skip
|
||||||
|
let iterationFetched = 0
|
||||||
|
do {
|
||||||
|
const toSkip = Math.min(QueryBuilder.maxLimit, skipRemaining)
|
||||||
|
this.setLimit(toSkip)
|
||||||
|
const { bookmark, rows } = await this.#execute()
|
||||||
|
this.setBookmark(bookmark)
|
||||||
|
iterationFetched = rows.length
|
||||||
|
skipRemaining -= rows.length
|
||||||
|
} while (skipRemaining > 0 && iterationFetched > 0)
|
||||||
|
|
||||||
|
this.#includeDocs = prevIncludeDocs
|
||||||
|
this.#limit = prevLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
async #execute() {
|
||||||
const { url, cookie } = getCouchInfo()
|
const { url, cookie } = getCouchInfo()
|
||||||
const fullPath = `${url}/${this.dbName}/_design/database/_search/${this.index}`
|
const fullPath = `${url}/${this.#dbName}/_design/database/_search/${
|
||||||
|
this.#index
|
||||||
|
}`
|
||||||
const body = this.buildSearchBody()
|
const body = this.buildSearchBody()
|
||||||
try {
|
try {
|
||||||
return await runQuery<T>(fullPath, body, cookie)
|
return await runQuery<T>(fullPath, body, cookie)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.status === 404 && this.indexBuilder) {
|
if (err.status === 404 && this.#indexBuilder) {
|
||||||
await this.indexBuilder()
|
await this.#indexBuilder()
|
||||||
return await runQuery<T>(fullPath, body, cookie)
|
return await runQuery<T>(fullPath, body, cookie)
|
||||||
} else {
|
} else {
|
||||||
throw err
|
throw err
|
||||||
|
@ -502,8 +553,9 @@ async function runQuery<T>(
|
||||||
}
|
}
|
||||||
const json = await response.json()
|
const json = await response.json()
|
||||||
|
|
||||||
let output: any = {
|
let output: SearchResponse<T> = {
|
||||||
rows: [],
|
rows: [],
|
||||||
|
totalRows: 0,
|
||||||
}
|
}
|
||||||
if (json.rows != null && json.rows.length > 0) {
|
if (json.rows != null && json.rows.length > 0) {
|
||||||
output.rows = json.rows.map((row: any) => row.doc)
|
output.rows = json.rows.map((row: any) => row.doc)
|
||||||
|
@ -511,6 +563,9 @@ async function runQuery<T>(
|
||||||
if (json.bookmark) {
|
if (json.bookmark) {
|
||||||
output.bookmark = json.bookmark
|
output.bookmark = json.bookmark
|
||||||
}
|
}
|
||||||
|
if (json.total_rows) {
|
||||||
|
output.totalRows = json.total_rows
|
||||||
|
}
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -543,8 +598,8 @@ async function recursiveSearch<T>(
|
||||||
if (rows.length >= params.limit) {
|
if (rows.length >= params.limit) {
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
let pageSize = 200
|
let pageSize = QueryBuilder.maxLimit
|
||||||
if (rows.length > params.limit - 200) {
|
if (rows.length > params.limit - QueryBuilder.maxLimit) {
|
||||||
pageSize = params.limit - rows.length
|
pageSize = params.limit - rows.length
|
||||||
}
|
}
|
||||||
const page = await new QueryBuilder<T>(dbName, index, query)
|
const page = await new QueryBuilder<T>(dbName, index, query)
|
||||||
|
@ -559,7 +614,7 @@ async function recursiveSearch<T>(
|
||||||
if (!page.rows.length) {
|
if (!page.rows.length) {
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
if (page.rows.length < 200) {
|
if (page.rows.length < QueryBuilder.maxLimit) {
|
||||||
return [...rows, ...page.rows]
|
return [...rows, ...page.rows]
|
||||||
}
|
}
|
||||||
const newParams = {
|
const newParams = {
|
||||||
|
@ -597,7 +652,7 @@ export async function paginatedSearch<T>(
|
||||||
if (limit == null || isNaN(limit) || limit < 0) {
|
if (limit == null || isNaN(limit) || limit < 0) {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
limit = Math.min(limit, 200)
|
limit = Math.min(limit, QueryBuilder.maxLimit)
|
||||||
const search = new QueryBuilder<T>(dbName, index, query)
|
const search = new QueryBuilder<T>(dbName, index, query)
|
||||||
if (params.version) {
|
if (params.version) {
|
||||||
search.setVersion(params.version)
|
search.setVersion(params.version)
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./searchIndexes"
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { User, SearchIndex } from "@budibase/types"
|
||||||
|
import { getGlobalDB } from "../../context"
|
||||||
|
|
||||||
|
export async function createUserIndex() {
|
||||||
|
const db = getGlobalDB()
|
||||||
|
let designDoc
|
||||||
|
try {
|
||||||
|
designDoc = await db.get("_design/database")
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.status === 404) {
|
||||||
|
designDoc = { _id: "_design/database" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn = function (user: User) {
|
||||||
|
if (user._id && !user._id.startsWith("us_")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ignoredFields = [
|
||||||
|
"_id",
|
||||||
|
"_rev",
|
||||||
|
"password",
|
||||||
|
"account",
|
||||||
|
"license",
|
||||||
|
"budibaseAccess",
|
||||||
|
"accountPortalAccess",
|
||||||
|
"csrfToken",
|
||||||
|
]
|
||||||
|
|
||||||
|
function idx(input: Record<string, any>, prev?: string) {
|
||||||
|
for (let key of Object.keys(input)) {
|
||||||
|
if (ignoredFields.includes(key)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let idxKey = prev != null ? `${prev}.${key}` : key
|
||||||
|
if (typeof input[key] === "string") {
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
// @ts-ignore
|
||||||
|
index(idxKey, input[key].toLowerCase(), { facet: true })
|
||||||
|
} else if (typeof input[key] !== "object") {
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
// @ts-ignore
|
||||||
|
index(idxKey, input[key], { facet: true })
|
||||||
|
} else {
|
||||||
|
idx(input[key], idxKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
designDoc.indexes = {
|
||||||
|
[SearchIndex.USER]: {
|
||||||
|
index: fn.toString(),
|
||||||
|
analyzer: {
|
||||||
|
default: "keyword",
|
||||||
|
name: "perfield",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await db.put(designDoc)
|
||||||
|
}
|
|
@ -136,6 +136,106 @@ describe("lucene", () => {
|
||||||
const resp = await builder.run()
|
const resp = await builder.run()
|
||||||
expect(resp.rows.length).toBe(2)
|
expect(resp.rows.length).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("skip", () => {
|
||||||
|
const skipDbName = `db-${newid()}`
|
||||||
|
let docs: {
|
||||||
|
_id: string
|
||||||
|
property: string
|
||||||
|
array: string[]
|
||||||
|
}[]
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const db = getDB(skipDbName)
|
||||||
|
|
||||||
|
docs = Array(QueryBuilder.maxLimit * 2.5)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, i) => ({
|
||||||
|
_id: i.toString().padStart(3, "0"),
|
||||||
|
property: `value_${i.toString().padStart(3, "0")}`,
|
||||||
|
array: [],
|
||||||
|
}))
|
||||||
|
await db.bulkDocs(docs)
|
||||||
|
|
||||||
|
await db.put({
|
||||||
|
_id: "_design/database",
|
||||||
|
indexes: {
|
||||||
|
[INDEX_NAME]: {
|
||||||
|
index: index,
|
||||||
|
analyzer: "standard",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to apply skip", async () => {
|
||||||
|
const builder = new QueryBuilder(skipDbName, INDEX_NAME)
|
||||||
|
const firstResponse = await builder.run()
|
||||||
|
builder.setSkip(40)
|
||||||
|
const secondResponse = await builder.run()
|
||||||
|
|
||||||
|
// Return the default limit
|
||||||
|
expect(firstResponse.rows.length).toBe(50)
|
||||||
|
expect(secondResponse.rows.length).toBe(50)
|
||||||
|
|
||||||
|
// Should have the expected overlap
|
||||||
|
expect(firstResponse.rows.slice(40)).toEqual(
|
||||||
|
secondResponse.rows.slice(0, 10)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle limits", async () => {
|
||||||
|
const builder = new QueryBuilder(skipDbName, INDEX_NAME)
|
||||||
|
builder.setLimit(10)
|
||||||
|
builder.setSkip(50)
|
||||||
|
builder.setSort("_id")
|
||||||
|
|
||||||
|
const resp = await builder.run()
|
||||||
|
expect(resp.rows.length).toBe(10)
|
||||||
|
expect(resp.rows).toEqual(
|
||||||
|
docs.slice(50, 60).map(expect.objectContaining)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to skip searching through multiple responses", async () => {
|
||||||
|
const builder = new QueryBuilder(skipDbName, INDEX_NAME)
|
||||||
|
// Skipping 2 max limits plus a little bit more
|
||||||
|
const skip = QueryBuilder.maxLimit * 2 + 37
|
||||||
|
builder.setSkip(skip)
|
||||||
|
builder.setSort("_id")
|
||||||
|
const resp = await builder.run()
|
||||||
|
|
||||||
|
expect(resp.rows.length).toBe(50)
|
||||||
|
expect(resp.rows).toEqual(
|
||||||
|
docs.slice(skip, skip + resp.rows.length).map(expect.objectContaining)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not return results if skipping all docs", async () => {
|
||||||
|
const builder = new QueryBuilder(skipDbName, INDEX_NAME)
|
||||||
|
// Skipping 2 max limits plus a little bit more
|
||||||
|
const skip = docs.length + 1
|
||||||
|
builder.setSkip(skip)
|
||||||
|
|
||||||
|
const resp = await builder.run()
|
||||||
|
|
||||||
|
expect(resp.rows.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("skip should respect with filters", async () => {
|
||||||
|
const builder = new QueryBuilder(skipDbName, INDEX_NAME)
|
||||||
|
builder.setLimit(10)
|
||||||
|
builder.setSkip(50)
|
||||||
|
builder.addString("property", "value_1")
|
||||||
|
builder.setSort("property")
|
||||||
|
|
||||||
|
const resp = await builder.run()
|
||||||
|
expect(resp.rows.length).toBe(10)
|
||||||
|
expect(resp.rows).toEqual(
|
||||||
|
docs.slice(150, 160).map(expect.objectContaining)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("paginated search", () => {
|
describe("paginated search", () => {
|
||||||
|
|
|
@ -434,8 +434,8 @@ export const getPluginParams = (pluginId?: string | null, otherProps = {}) => {
|
||||||
return getDocParams(DocumentType.PLUGIN, pluginId, otherProps)
|
return getDocParams(DocumentType.PLUGIN, pluginId, otherProps)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pagination(
|
export function pagination<T>(
|
||||||
data: any[],
|
data: T[],
|
||||||
pageSize: number,
|
pageSize: number,
|
||||||
{
|
{
|
||||||
paginate,
|
paginate,
|
||||||
|
@ -444,7 +444,7 @@ export function pagination(
|
||||||
}: {
|
}: {
|
||||||
paginate: boolean
|
paginate: boolean
|
||||||
property: string
|
property: string
|
||||||
getKey?: (doc: any) => string | undefined
|
getKey?: (doc: T) => string | undefined
|
||||||
} = {
|
} = {
|
||||||
paginate: true,
|
paginate: true,
|
||||||
property: "_id",
|
property: "_id",
|
||||||
|
|
|
@ -9,12 +9,13 @@ import {
|
||||||
GroupUsersDeletedEvent,
|
GroupUsersDeletedEvent,
|
||||||
GroupAddedOnboardingEvent,
|
GroupAddedOnboardingEvent,
|
||||||
GroupPermissionsEditedEvent,
|
GroupPermissionsEditedEvent,
|
||||||
UserGroupRoles,
|
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
|
import { isScim } from "../../context"
|
||||||
|
|
||||||
async function created(group: UserGroup, timestamp?: number) {
|
async function created(group: UserGroup, timestamp?: number) {
|
||||||
const properties: GroupCreatedEvent = {
|
const properties: GroupCreatedEvent = {
|
||||||
groupId: group._id as string,
|
groupId: group._id as string,
|
||||||
|
viaScim: isScim(),
|
||||||
audited: {
|
audited: {
|
||||||
name: group.name,
|
name: group.name,
|
||||||
},
|
},
|
||||||
|
@ -25,6 +26,7 @@ async function created(group: UserGroup, timestamp?: number) {
|
||||||
async function updated(group: UserGroup) {
|
async function updated(group: UserGroup) {
|
||||||
const properties: GroupUpdatedEvent = {
|
const properties: GroupUpdatedEvent = {
|
||||||
groupId: group._id as string,
|
groupId: group._id as string,
|
||||||
|
viaScim: isScim(),
|
||||||
audited: {
|
audited: {
|
||||||
name: group.name,
|
name: group.name,
|
||||||
},
|
},
|
||||||
|
@ -35,6 +37,7 @@ async function updated(group: UserGroup) {
|
||||||
async function deleted(group: UserGroup) {
|
async function deleted(group: UserGroup) {
|
||||||
const properties: GroupDeletedEvent = {
|
const properties: GroupDeletedEvent = {
|
||||||
groupId: group._id as string,
|
groupId: group._id as string,
|
||||||
|
viaScim: isScim(),
|
||||||
audited: {
|
audited: {
|
||||||
name: group.name,
|
name: group.name,
|
||||||
},
|
},
|
||||||
|
@ -46,6 +49,7 @@ async function usersAdded(count: number, group: UserGroup) {
|
||||||
const properties: GroupUsersAddedEvent = {
|
const properties: GroupUsersAddedEvent = {
|
||||||
count,
|
count,
|
||||||
groupId: group._id as string,
|
groupId: group._id as string,
|
||||||
|
viaScim: isScim(),
|
||||||
audited: {
|
audited: {
|
||||||
name: group.name,
|
name: group.name,
|
||||||
},
|
},
|
||||||
|
@ -57,6 +61,7 @@ async function usersDeleted(count: number, group: UserGroup) {
|
||||||
const properties: GroupUsersDeletedEvent = {
|
const properties: GroupUsersDeletedEvent = {
|
||||||
count,
|
count,
|
||||||
groupId: group._id as string,
|
groupId: group._id as string,
|
||||||
|
viaScim: isScim(),
|
||||||
audited: {
|
audited: {
|
||||||
name: group.name,
|
name: group.name,
|
||||||
},
|
},
|
||||||
|
|
|
@ -15,10 +15,12 @@ import {
|
||||||
UserUpdatedEvent,
|
UserUpdatedEvent,
|
||||||
UserOnboardingEvent,
|
UserOnboardingEvent,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
|
import { isScim } from "../../context"
|
||||||
|
|
||||||
async function created(user: User, timestamp?: number) {
|
async function created(user: User, timestamp?: number) {
|
||||||
const properties: UserCreatedEvent = {
|
const properties: UserCreatedEvent = {
|
||||||
userId: user._id as string,
|
userId: user._id as string,
|
||||||
|
viaScim: isScim(),
|
||||||
audited: {
|
audited: {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
},
|
},
|
||||||
|
@ -29,6 +31,7 @@ async function created(user: User, timestamp?: number) {
|
||||||
async function updated(user: User) {
|
async function updated(user: User) {
|
||||||
const properties: UserUpdatedEvent = {
|
const properties: UserUpdatedEvent = {
|
||||||
userId: user._id as string,
|
userId: user._id as string,
|
||||||
|
viaScim: isScim(),
|
||||||
audited: {
|
audited: {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
},
|
},
|
||||||
|
@ -39,6 +42,7 @@ async function updated(user: User) {
|
||||||
async function deleted(user: User) {
|
async function deleted(user: User) {
|
||||||
const properties: UserDeletedEvent = {
|
const properties: UserDeletedEvent = {
|
||||||
userId: user._id as string,
|
userId: user._id as string,
|
||||||
|
viaScim: isScim(),
|
||||||
audited: {
|
audited: {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
},
|
},
|
||||||
|
|
|
@ -96,9 +96,15 @@ export default function (
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// check the actual user is authenticated first, try header or cookie
|
// check the actual user is authenticated first, try header or cookie
|
||||||
const headerToken = ctx.request.headers[Header.TOKEN]
|
let headerToken = ctx.request.headers[Header.TOKEN]
|
||||||
|
|
||||||
const authCookie = getCookie(ctx, Cookie.Auth) || openJwt(headerToken)
|
const authCookie = getCookie(ctx, Cookie.Auth) || openJwt(headerToken)
|
||||||
const apiKey = ctx.request.headers[Header.API_KEY]
|
let apiKey = ctx.request.headers[Header.API_KEY]
|
||||||
|
|
||||||
|
if (!apiKey && ctx.request.headers[Header.AUTHORIZATION]) {
|
||||||
|
apiKey = ctx.request.headers[Header.AUTHORIZATION].split(" ")[1]
|
||||||
|
}
|
||||||
|
|
||||||
const tenantId = ctx.request.headers[Header.TENANT_ID]
|
const tenantId = ctx.request.headers[Header.TENANT_ID]
|
||||||
let authenticated = false,
|
let authenticated = false,
|
||||||
user = null,
|
user = null,
|
||||||
|
|
|
@ -8,8 +8,10 @@ import {
|
||||||
DocumentType,
|
DocumentType,
|
||||||
SEPARATOR,
|
SEPARATOR,
|
||||||
directCouchFind,
|
directCouchFind,
|
||||||
|
getGlobalUserParams,
|
||||||
|
pagination,
|
||||||
} from "./db"
|
} from "./db"
|
||||||
import { BulkDocsResponse, User } from "@budibase/types"
|
import { BulkDocsResponse, SearchUsersRequest, User } from "@budibase/types"
|
||||||
import { getGlobalDB } from "./context"
|
import { getGlobalDB } from "./context"
|
||||||
import * as context from "./context"
|
import * as context from "./context"
|
||||||
|
|
||||||
|
@ -199,3 +201,41 @@ export const searchGlobalUsersByEmail = async (
|
||||||
}
|
}
|
||||||
return users
|
return users
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAGE_LIMIT = 8
|
||||||
|
export const paginatedUsers = async ({
|
||||||
|
page,
|
||||||
|
email,
|
||||||
|
appId,
|
||||||
|
}: SearchUsersRequest = {}) => {
|
||||||
|
const db = getGlobalDB()
|
||||||
|
// get one extra document, to have the next page
|
||||||
|
const opts: any = {
|
||||||
|
include_docs: true,
|
||||||
|
limit: PAGE_LIMIT + 1,
|
||||||
|
}
|
||||||
|
// add a startkey if the page was specified (anchor)
|
||||||
|
if (page) {
|
||||||
|
opts.startkey = page
|
||||||
|
}
|
||||||
|
// property specifies what to use for the page/anchor
|
||||||
|
let userList: User[],
|
||||||
|
property = "_id",
|
||||||
|
getKey
|
||||||
|
if (appId) {
|
||||||
|
userList = await searchGlobalUsersByApp(appId, opts)
|
||||||
|
getKey = (doc: any) => getGlobalUserByAppPage(appId, doc)
|
||||||
|
} else if (email) {
|
||||||
|
userList = await searchGlobalUsersByEmail(email, opts)
|
||||||
|
property = "email"
|
||||||
|
} else {
|
||||||
|
// no search, query allDocs
|
||||||
|
const response = await db.allDocs(getGlobalUserParams(null, opts))
|
||||||
|
userList = response.rows.map((row: any) => row.doc)
|
||||||
|
}
|
||||||
|
return pagination(userList, PAGE_LIMIT, {
|
||||||
|
paginate: true,
|
||||||
|
property,
|
||||||
|
getKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -86,6 +86,10 @@ export const useAuditLogs = () => {
|
||||||
return useFeature(Feature.AUDIT_LOGS)
|
return useFeature(Feature.AUDIT_LOGS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useScimIntegration = () => {
|
||||||
|
return useFeature(Feature.SCIM)
|
||||||
|
}
|
||||||
|
|
||||||
// QUOTAS
|
// QUOTAS
|
||||||
|
|
||||||
export const setAutomationLogsQuota = (value: number) => {
|
export const setAutomationLogsQuota = (value: number) => {
|
||||||
|
|
|
@ -10,3 +10,4 @@ export * as tenant from "./tenants"
|
||||||
export * as users from "./users"
|
export * as users from "./users"
|
||||||
export * as userGroups from "./userGroups"
|
export * as userGroups from "./userGroups"
|
||||||
export { generator } from "./generator"
|
export { generator } from "./generator"
|
||||||
|
export * as scim from "./scim"
|
||||||
|
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { ScimCreateGroupRequest, ScimCreateUserRequest } from "@budibase/types"
|
||||||
|
import { uuid } from "./common"
|
||||||
|
import { generator } from "./generator"
|
||||||
|
|
||||||
|
export function createUserRequest(userData?: {
|
||||||
|
externalId?: string
|
||||||
|
email?: string
|
||||||
|
firstName?: string
|
||||||
|
lastName?: string
|
||||||
|
username?: string
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
externalId = uuid(),
|
||||||
|
email = generator.email(),
|
||||||
|
firstName = generator.first(),
|
||||||
|
lastName = generator.last(),
|
||||||
|
username = generator.name(),
|
||||||
|
} = userData || {}
|
||||||
|
|
||||||
|
const user: ScimCreateUserRequest = {
|
||||||
|
schemas: [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||||
|
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
|
||||||
|
],
|
||||||
|
externalId,
|
||||||
|
userName: username,
|
||||||
|
active: true,
|
||||||
|
emails: [
|
||||||
|
{
|
||||||
|
primary: true,
|
||||||
|
type: "work",
|
||||||
|
value: email,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: {
|
||||||
|
resourceType: "User",
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
formatted: generator.name(),
|
||||||
|
familyName: lastName,
|
||||||
|
givenName: firstName,
|
||||||
|
},
|
||||||
|
roles: [],
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGroupRequest(groupData?: {
|
||||||
|
externalId?: string
|
||||||
|
displayName?: string
|
||||||
|
}) {
|
||||||
|
const { externalId = uuid(), displayName = generator.word() } =
|
||||||
|
groupData || {}
|
||||||
|
|
||||||
|
const group: ScimCreateGroupRequest = {
|
||||||
|
schemas: [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:Group",
|
||||||
|
"http://schemas.microsoft.com/2006/11/ResourceManagement/ADSCIM/2.0/Group",
|
||||||
|
],
|
||||||
|
externalId: externalId,
|
||||||
|
displayName: displayName,
|
||||||
|
meta: {
|
||||||
|
resourceType: "Group",
|
||||||
|
created: new Date(),
|
||||||
|
lastModified: new Date(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return group
|
||||||
|
}
|
|
@ -27,6 +27,7 @@
|
||||||
import { onMount } from "svelte"
|
import { onMount } from "svelte"
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
import { organisation, admin, licensing } from "stores/portal"
|
import { organisation, admin, licensing } from "stores/portal"
|
||||||
|
import Scim from "./scim.svelte"
|
||||||
|
|
||||||
const ConfigTypes = {
|
const ConfigTypes = {
|
||||||
Google: "google",
|
Google: "google",
|
||||||
|
@ -606,12 +607,17 @@
|
||||||
</Tags>
|
</Tags>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Button disabled={oidcSaveButtonDisabled} cta on:click={() => saveOIDC()}>
|
<Button disabled={oidcSaveButtonDisabled} cta on:click={() => saveOIDC()}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if $licensing.scimEnabled}
|
||||||
|
<Divider />
|
||||||
|
<Scim />
|
||||||
|
{/if}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
@ -0,0 +1,161 @@
|
||||||
|
<script>
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Heading,
|
||||||
|
Label,
|
||||||
|
notifications,
|
||||||
|
Layout,
|
||||||
|
Body,
|
||||||
|
Toggle,
|
||||||
|
Input,
|
||||||
|
Icon,
|
||||||
|
Helpers,
|
||||||
|
} from "@budibase/bbui"
|
||||||
|
import { onMount } from "svelte"
|
||||||
|
import { API } from "api"
|
||||||
|
import { organisation, auth } from "stores/portal"
|
||||||
|
|
||||||
|
const configType = "scim"
|
||||||
|
|
||||||
|
$: scimEnabled = false
|
||||||
|
$: apiKey = null
|
||||||
|
|
||||||
|
async function saveSCIM() {
|
||||||
|
try {
|
||||||
|
await API.saveConfig({
|
||||||
|
type: configType,
|
||||||
|
config: {
|
||||||
|
enabled: scimEnabled,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
notifications.success(`Settings saved`)
|
||||||
|
} catch (e) {
|
||||||
|
notifications.error(e.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
try {
|
||||||
|
const scimConfig = await API.getConfig(configType)
|
||||||
|
scimEnabled = scimConfig?.config?.enabled
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
notifications.error(
|
||||||
|
`Error fetching SCIM config - ${error?.message || "unknown error"}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAPIKey() {
|
||||||
|
try {
|
||||||
|
apiKey = await auth.fetchAPIKey()
|
||||||
|
} catch (err) {
|
||||||
|
notifications.error(
|
||||||
|
`Unable to fetch API key - ${err?.message || "unknown error"}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await Promise.all(fetchConfig(), fetchAPIKey())
|
||||||
|
})
|
||||||
|
|
||||||
|
const copyToClipboard = async value => {
|
||||||
|
await Helpers.copyToClipboard(value)
|
||||||
|
notifications.success("Copied")
|
||||||
|
}
|
||||||
|
|
||||||
|
$: settings = [
|
||||||
|
{
|
||||||
|
title: "Provisioning URL",
|
||||||
|
value: `${$organisation.platformUrl}/api/global/scim/v2`,
|
||||||
|
},
|
||||||
|
{ title: "Provisioning Token", value: apiKey },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Layout gap="XS" noPadding>
|
||||||
|
<div class="provider-title">
|
||||||
|
<Heading size="S">SCIM</Heading>
|
||||||
|
</div>
|
||||||
|
<Body size="S">Sync users with your identity provider.</Body>
|
||||||
|
<div class="form-row">
|
||||||
|
<Label size="L">Activated</Label>
|
||||||
|
<Toggle text="" bind:value={scimEnabled} />
|
||||||
|
</div>
|
||||||
|
{#if scimEnabled}
|
||||||
|
{#each settings as setting}
|
||||||
|
<div class="form-row">
|
||||||
|
<Label size="L" tooltip={""}>{setting.title}</Label>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<div class="input">
|
||||||
|
<Input value={setting.value} readonly={true} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="copy" on:click={() => copyToClipboard(setting.value)}>
|
||||||
|
<Icon size="S" name="Copy" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button cta on:click={saveSCIM}>Save</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TODO: DRY -->
|
||||||
|
<style>
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px 1fr;
|
||||||
|
grid-gap: var(--spacing-l);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.sso-link-icon {
|
||||||
|
padding-top: 4px;
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
.sso-link {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.enforce-sso-title {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
.enforce-sso-heading-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.provider-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-m);
|
||||||
|
}
|
||||||
|
.provider-title span {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
.inputContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.copy {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,15 @@
|
||||||
|
<script>
|
||||||
|
import { Icon } from "@budibase/bbui"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="scim-banner">
|
||||||
|
<Icon name="Info" size="S" />
|
||||||
|
Users are synced from your AD
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scim-banner {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-s);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,12 +1,23 @@
|
||||||
<script>
|
<script>
|
||||||
import { Page } from "@budibase/bbui"
|
import { Page, notifications } from "@budibase/bbui"
|
||||||
|
import { onMount } from "svelte"
|
||||||
import { SideNav, SideNavItem, Content } from "components/portal/page"
|
import { SideNav, SideNavItem, Content } from "components/portal/page"
|
||||||
import { isActive, goto } from "@roxi/routify"
|
import { isActive, goto } from "@roxi/routify"
|
||||||
import { menu } from "stores/portal"
|
import { menu, features } from "stores/portal"
|
||||||
|
|
||||||
$: wide = $isActive("./users/index") || $isActive("./groups/index")
|
$: wide = $isActive("./users/index") || $isActive("./groups/index")
|
||||||
$: pages = $menu.find(x => x.title === "Users")?.subPages || []
|
$: pages = $menu.find(x => x.title === "Users")?.subPages || []
|
||||||
$: !pages.length && $goto("../")
|
$: !pages.length && $goto("../")
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
await features.init()
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error(
|
||||||
|
`Error fetching feature configs - ${error?.message || "unknown error"}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page>
|
<Page>
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
|
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
|
||||||
import { createPaginationStore } from "helpers/pagination"
|
import { createPaginationStore } from "helpers/pagination"
|
||||||
import { users, apps, groups, auth } from "stores/portal"
|
import { users, apps, groups, auth, features } from "stores/portal"
|
||||||
import { onMount, setContext } from "svelte"
|
import { onMount, setContext } from "svelte"
|
||||||
import { roles } from "stores/backend"
|
import { roles } from "stores/backend"
|
||||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||||
|
@ -24,18 +24,23 @@
|
||||||
import AppNameTableRenderer from "../users/_components/AppNameTableRenderer.svelte"
|
import AppNameTableRenderer from "../users/_components/AppNameTableRenderer.svelte"
|
||||||
import RemoveUserTableRenderer from "./_components/RemoveUserTableRenderer.svelte"
|
import RemoveUserTableRenderer from "./_components/RemoveUserTableRenderer.svelte"
|
||||||
import AppRoleTableRenderer from "../users/_components/AppRoleTableRenderer.svelte"
|
import AppRoleTableRenderer from "../users/_components/AppRoleTableRenderer.svelte"
|
||||||
|
import ScimBanner from "../_components/SCIMBanner.svelte"
|
||||||
|
|
||||||
export let groupId
|
export let groupId
|
||||||
|
|
||||||
const userSchema = {
|
$: userSchema = {
|
||||||
email: {
|
email: {
|
||||||
width: "1fr",
|
width: "1fr",
|
||||||
},
|
},
|
||||||
_id: {
|
...(readonly
|
||||||
displayName: "",
|
? {}
|
||||||
width: "auto",
|
: {
|
||||||
borderLeft: true,
|
_id: {
|
||||||
},
|
displayName: "",
|
||||||
|
width: "auto",
|
||||||
|
borderLeft: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
const appSchema = {
|
const appSchema = {
|
||||||
name: {
|
name: {
|
||||||
|
@ -70,7 +75,9 @@
|
||||||
let loaded = false
|
let loaded = false
|
||||||
let editModal, deleteModal
|
let editModal, deleteModal
|
||||||
|
|
||||||
$: readonly = !$auth.isAdmin
|
const scimEnabled = $features.isScimEnabled
|
||||||
|
|
||||||
|
$: readonly = !$auth.isAdmin || scimEnabled
|
||||||
$: page = $pageInfo.page
|
$: page = $pageInfo.page
|
||||||
$: fetchUsers(page, searchTerm)
|
$: fetchUsers(page, searchTerm)
|
||||||
$: group = $groups.find(x => x._id === groupId)
|
$: group = $groups.find(x => x._id === groupId)
|
||||||
|
@ -182,11 +189,15 @@
|
||||||
<Layout noPadding gap="S">
|
<Layout noPadding gap="S">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<Heading size="S">Users</Heading>
|
<Heading size="S">Users</Heading>
|
||||||
<div bind:this={popoverAnchor}>
|
{#if !scimEnabled}
|
||||||
<Button disabled={readonly} on:click={popover.show()} cta
|
<div bind:this={popoverAnchor}>
|
||||||
>Add user</Button
|
<Button disabled={readonly} on:click={popover.show()} cta
|
||||||
>
|
>Add user</Button
|
||||||
</div>
|
>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ScimBanner />
|
||||||
|
{/if}
|
||||||
<Popover align="right" bind:this={popover} anchor={popoverAnchor}>
|
<Popover align="right" bind:this={popover} anchor={popoverAnchor}>
|
||||||
<UserGroupPicker
|
<UserGroupPicker
|
||||||
bind:searchTerm
|
bind:searchTerm
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
Search,
|
Search,
|
||||||
notifications,
|
notifications,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import { groups, auth, licensing, admin } from "stores/portal"
|
import { groups, auth, licensing, admin, features } from "stores/portal"
|
||||||
import { onMount } from "svelte"
|
import { onMount } from "svelte"
|
||||||
import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte"
|
import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte"
|
||||||
import { cloneDeep } from "lodash/fp"
|
import { cloneDeep } from "lodash/fp"
|
||||||
|
@ -21,6 +21,7 @@
|
||||||
import UsersTableRenderer from "./_components/UsersTableRenderer.svelte"
|
import UsersTableRenderer from "./_components/UsersTableRenderer.svelte"
|
||||||
import GroupNameTableRenderer from "./_components/GroupNameTableRenderer.svelte"
|
import GroupNameTableRenderer from "./_components/GroupNameTableRenderer.svelte"
|
||||||
import { goto } from "@roxi/routify"
|
import { goto } from "@roxi/routify"
|
||||||
|
import ScimBanner from "../_components/SCIMBanner.svelte"
|
||||||
|
|
||||||
const DefaultGroup = {
|
const DefaultGroup = {
|
||||||
name: "",
|
name: "",
|
||||||
|
@ -106,10 +107,14 @@
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<ButtonGroup>
|
<ButtonGroup>
|
||||||
{#if $licensing.groupsEnabled}
|
{#if $licensing.groupsEnabled}
|
||||||
<!--Show the group create button-->
|
{#if !$features.isScimEnabled}
|
||||||
<Button disabled={readonly} cta on:click={showCreateGroupModal}>
|
<!--Show the group create button-->
|
||||||
Add group
|
<Button disabled={readonly} cta on:click={showCreateGroupModal}>
|
||||||
</Button>
|
Add group
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<ScimBanner />
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<Button
|
<Button
|
||||||
primary
|
primary
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
Table,
|
Table,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import { onMount, setContext } from "svelte"
|
import { onMount, setContext } from "svelte"
|
||||||
import { users, auth, groups, apps, licensing } from "stores/portal"
|
import { users, auth, groups, apps, licensing, features } from "stores/portal"
|
||||||
import { roles } from "stores/backend"
|
import { roles } from "stores/backend"
|
||||||
import ForceResetPasswordModal from "./_components/ForceResetPasswordModal.svelte"
|
import ForceResetPasswordModal from "./_components/ForceResetPasswordModal.svelte"
|
||||||
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
|
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
|
||||||
|
@ -31,18 +31,23 @@
|
||||||
import GroupNameTableRenderer from "../groups/_components/GroupNameTableRenderer.svelte"
|
import GroupNameTableRenderer from "../groups/_components/GroupNameTableRenderer.svelte"
|
||||||
import AppNameTableRenderer from "./_components/AppNameTableRenderer.svelte"
|
import AppNameTableRenderer from "./_components/AppNameTableRenderer.svelte"
|
||||||
import AppRoleTableRenderer from "./_components/AppRoleTableRenderer.svelte"
|
import AppRoleTableRenderer from "./_components/AppRoleTableRenderer.svelte"
|
||||||
|
import ScimBanner from "../_components/SCIMBanner.svelte"
|
||||||
|
|
||||||
export let userId
|
export let userId
|
||||||
|
|
||||||
const groupSchema = {
|
$: groupSchema = {
|
||||||
name: {
|
name: {
|
||||||
width: "1fr",
|
width: "1fr",
|
||||||
},
|
},
|
||||||
_id: {
|
...(readonly
|
||||||
displayName: "",
|
? {}
|
||||||
width: "auto",
|
: {
|
||||||
borderLeft: true,
|
_id: {
|
||||||
},
|
displayName: "",
|
||||||
|
width: "auto",
|
||||||
|
borderLeft: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
const appSchema = {
|
const appSchema = {
|
||||||
name: {
|
name: {
|
||||||
|
@ -81,9 +86,10 @@
|
||||||
let user
|
let user
|
||||||
let loaded = false
|
let loaded = false
|
||||||
|
|
||||||
|
const scimEnabled = $features.isScimEnabled
|
||||||
|
|
||||||
$: isSSO = !!user?.provider
|
$: isSSO = !!user?.provider
|
||||||
$: readonly = !$auth.isAdmin
|
$: readonly = !$auth.isAdmin || scimEnabled
|
||||||
$: fullName = user?.firstName ? user?.firstName + " " + user?.lastName : ""
|
|
||||||
$: privileged = user?.admin?.global || user?.builder?.global
|
$: privileged = user?.admin?.global || user?.builder?.global
|
||||||
$: nameLabel = getNameLabel(user)
|
$: nameLabel = getNameLabel(user)
|
||||||
$: initials = getInitials(nameLabel)
|
$: initials = getInitials(nameLabel)
|
||||||
|
@ -260,7 +266,12 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<Layout noPadding gap="S">
|
<Layout noPadding gap="S">
|
||||||
<Heading size="S">Details</Heading>
|
<div class="details-title">
|
||||||
|
<Heading size="S">Details</Heading>
|
||||||
|
{#if scimEnabled}
|
||||||
|
<ScimBanner />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<Label size="L">Email</Label>
|
<Label size="L">Email</Label>
|
||||||
|
@ -284,10 +295,11 @@
|
||||||
</div>
|
</div>
|
||||||
<!-- don't let a user remove the privileges that let them be here -->
|
<!-- don't let a user remove the privileges that let them be here -->
|
||||||
{#if userId !== $auth.user._id}
|
{#if userId !== $auth.user._id}
|
||||||
|
<!-- Disabled if it's not admin, enabled for SCIM integration -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<Label size="L">Role</Label>
|
<Label size="L">Role</Label>
|
||||||
<Select
|
<Select
|
||||||
disabled={readonly}
|
disabled={!$auth.isAdmin}
|
||||||
value={globalRole}
|
value={globalRole}
|
||||||
options={Constants.BudibaseRoleOptions}
|
options={Constants.BudibaseRoleOptions}
|
||||||
on:change={updateUserRole}
|
on:change={updateUserRole}
|
||||||
|
@ -404,4 +416,9 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.details-title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -13,7 +13,14 @@
|
||||||
Divider,
|
Divider,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import AddUserModal from "./_components/AddUserModal.svelte"
|
import AddUserModal from "./_components/AddUserModal.svelte"
|
||||||
import { users, groups, auth, licensing, organisation } from "stores/portal"
|
import {
|
||||||
|
users,
|
||||||
|
groups,
|
||||||
|
auth,
|
||||||
|
licensing,
|
||||||
|
organisation,
|
||||||
|
features,
|
||||||
|
} from "stores/portal"
|
||||||
import { onMount } from "svelte"
|
import { onMount } from "svelte"
|
||||||
import DeleteRowsButton from "components/backend/DataTable/buttons/DeleteRowsButton.svelte"
|
import DeleteRowsButton from "components/backend/DataTable/buttons/DeleteRowsButton.svelte"
|
||||||
import GroupsTableRenderer from "./_components/GroupsTableRenderer.svelte"
|
import GroupsTableRenderer from "./_components/GroupsTableRenderer.svelte"
|
||||||
|
@ -28,6 +35,7 @@
|
||||||
import { Constants, Utils, fetchData } from "@budibase/frontend-core"
|
import { Constants, Utils, fetchData } from "@budibase/frontend-core"
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
import { OnboardingType } from "../../../../../constants"
|
import { OnboardingType } from "../../../../../constants"
|
||||||
|
import ScimBanner from "../_components/SCIMBanner.svelte"
|
||||||
|
|
||||||
const fetch = fetchData({
|
const fetch = fetchData({
|
||||||
API,
|
API,
|
||||||
|
@ -53,7 +61,7 @@
|
||||||
]
|
]
|
||||||
let userData = []
|
let userData = []
|
||||||
|
|
||||||
$: readonly = !$auth.isAdmin
|
$: readonly = !$auth.isAdmin || $features.isScimEnabled
|
||||||
$: debouncedUpdateFetch(searchEmail)
|
$: debouncedUpdateFetch(searchEmail)
|
||||||
$: schema = {
|
$: schema = {
|
||||||
email: {
|
email: {
|
||||||
|
@ -230,14 +238,18 @@
|
||||||
</Layout>
|
</Layout>
|
||||||
<Divider />
|
<Divider />
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<ButtonGroup>
|
{#if !readonly}
|
||||||
<Button disabled={readonly} on:click={createUserModal.show} cta>
|
<ButtonGroup>
|
||||||
Add users
|
<Button disabled={readonly} on:click={createUserModal.show} cta>
|
||||||
</Button>
|
Add users
|
||||||
<Button disabled={readonly} on:click={importUsersModal.show} secondary>
|
</Button>
|
||||||
Import
|
<Button disabled={readonly} on:click={importUsersModal.show} secondary>
|
||||||
</Button>
|
Import
|
||||||
</ButtonGroup>
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
{:else}
|
||||||
|
<ScimBanner />
|
||||||
|
{/if}
|
||||||
<div class="controls-right">
|
<div class="controls-right">
|
||||||
<Search bind:value={searchEmail} placeholder="Search" />
|
<Search bind:value={searchEmail} placeholder="Search" />
|
||||||
{#if selectedRows.length > 0}
|
{#if selectedRows.length > 0}
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { writable } from "svelte/store"
|
||||||
|
import { API } from "api"
|
||||||
|
import { licensing } from "./licensing"
|
||||||
|
import { ConfigType } from "../../../../types/src/documents"
|
||||||
|
|
||||||
|
export const createFeatureStore = () => {
|
||||||
|
const internalStore = writable({
|
||||||
|
scim: {
|
||||||
|
isFeatureFlagEnabled: false,
|
||||||
|
isConfigFlagEnabled: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = writable({
|
||||||
|
isScimEnabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
internalStore.subscribe(s => {
|
||||||
|
store.update(state => ({
|
||||||
|
...state,
|
||||||
|
isScimEnabled: s.scim.isFeatureFlagEnabled && s.scim.isConfigFlagEnabled,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
licensing.subscribe(v => {
|
||||||
|
internalStore.update(state => ({
|
||||||
|
...state,
|
||||||
|
scim: {
|
||||||
|
...state.scim,
|
||||||
|
isFeatureFlagEnabled: v.scimEnabled,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
init: async () => {
|
||||||
|
const scimConfig = await API.getConfig(ConfigType.SCIM)
|
||||||
|
internalStore.update(state => ({
|
||||||
|
...state,
|
||||||
|
scim: {
|
||||||
|
...state.scim,
|
||||||
|
isConfigFlagEnabled: scimConfig?.config?.enabled,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe: store.subscribe,
|
||||||
|
...actions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const features = createFeatureStore()
|
|
@ -14,3 +14,4 @@ export { overview } from "./overview"
|
||||||
export { environment } from "./environment"
|
export { environment } from "./environment"
|
||||||
export { menu } from "./menu"
|
export { menu } from "./menu"
|
||||||
export { auditLogs } from "./auditLogs"
|
export { auditLogs } from "./auditLogs"
|
||||||
|
export { features } from "./features"
|
||||||
|
|
|
@ -18,6 +18,7 @@ export const createLicensingStore = () => {
|
||||||
groupsEnabled: false,
|
groupsEnabled: false,
|
||||||
backupsEnabled: false,
|
backupsEnabled: false,
|
||||||
brandingEnabled: false,
|
brandingEnabled: false,
|
||||||
|
scimEnabled: false,
|
||||||
// the currently used quotas from the db
|
// the currently used quotas from the db
|
||||||
quotaUsage: undefined,
|
quotaUsage: undefined,
|
||||||
// derived quota metrics for percentages used
|
// derived quota metrics for percentages used
|
||||||
|
@ -66,6 +67,7 @@ export const createLicensingStore = () => {
|
||||||
const backupsEnabled = license.features.includes(
|
const backupsEnabled = license.features.includes(
|
||||||
Constants.Features.BACKUPS
|
Constants.Features.BACKUPS
|
||||||
)
|
)
|
||||||
|
const scimEnabled = license.features.includes(Constants.Features.SCIM)
|
||||||
const environmentVariablesEnabled = license.features.includes(
|
const environmentVariablesEnabled = license.features.includes(
|
||||||
Constants.Features.ENVIRONMENT_VARIABLES
|
Constants.Features.ENVIRONMENT_VARIABLES
|
||||||
)
|
)
|
||||||
|
@ -88,6 +90,7 @@ export const createLicensingStore = () => {
|
||||||
groupsEnabled,
|
groupsEnabled,
|
||||||
backupsEnabled,
|
backupsEnabled,
|
||||||
brandingEnabled,
|
brandingEnabled,
|
||||||
|
scimEnabled,
|
||||||
environmentVariablesEnabled,
|
environmentVariablesEnabled,
|
||||||
auditLogsEnabled,
|
auditLogsEnabled,
|
||||||
enforceableSSO,
|
enforceableSSO,
|
||||||
|
|
|
@ -69,6 +69,7 @@ export const Features = {
|
||||||
AUDIT_LOGS: "auditLogs",
|
AUDIT_LOGS: "auditLogs",
|
||||||
ENFORCEABLE_SSO: "enforceableSSO",
|
ENFORCEABLE_SSO: "enforceableSSO",
|
||||||
BRANDING: "branding",
|
BRANDING: "branding",
|
||||||
|
SCIM: "scim",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role IDs
|
// Role IDs
|
||||||
|
|
|
@ -29,5 +29,8 @@
|
||||||
"koa-body": "4.2.0",
|
"koa-body": "4.2.0",
|
||||||
"rimraf": "3.0.2",
|
"rimraf": "3.0.2",
|
||||||
"typescript": "4.7.3"
|
"typescript": "4.7.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"scim-patch": "^0.7.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,3 +2,4 @@ export * from "./environmentVariables"
|
||||||
export * from "./auditLogs"
|
export * from "./auditLogs"
|
||||||
export * from "./events"
|
export * from "./events"
|
||||||
export * from "./configs"
|
export * from "./configs"
|
||||||
|
export * from "./scim"
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { ScimResource, ScimMeta } from "scim-patch"
|
||||||
|
import { ScimListResponse } from "./shared"
|
||||||
|
|
||||||
|
export interface ScimGroupResponse extends ScimResource {
|
||||||
|
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"]
|
||||||
|
id: string
|
||||||
|
externalId: string
|
||||||
|
displayName: string
|
||||||
|
meta: ScimMeta & {
|
||||||
|
resourceType: "Group"
|
||||||
|
}
|
||||||
|
members?: {
|
||||||
|
value: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScimCreateGroupRequest {
|
||||||
|
schemas: [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:Group",
|
||||||
|
"http://schemas.microsoft.com/2006/11/ResourceManagement/ADSCIM/2.0/Group"
|
||||||
|
]
|
||||||
|
externalId: string
|
||||||
|
displayName: string
|
||||||
|
meta: ScimMeta & {
|
||||||
|
resourceType: "Group"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScimGroupListResponse
|
||||||
|
extends ScimListResponse<ScimGroupResponse> {}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from "./users"
|
||||||
|
export * from "./groups"
|
||||||
|
export * from "./shared"
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { ScimPatchOperation } from "scim-patch"
|
||||||
|
|
||||||
|
export interface ScimListResponse<T> {
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"]
|
||||||
|
totalResults: number
|
||||||
|
Resources: T[]
|
||||||
|
startIndex: number
|
||||||
|
itemsPerPage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScimUpdateRequest {
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"]
|
||||||
|
Operations: ScimPatchOperation[]
|
||||||
|
}
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { ScimResource, ScimMeta, ScimPatchOperation } from "scim-patch"
|
||||||
|
import { ScimListResponse } from "./shared"
|
||||||
|
|
||||||
|
type BooleanString = boolean | "True" | "true" | "False" | "false"
|
||||||
|
|
||||||
|
export interface ScimUserResponse extends ScimResource {
|
||||||
|
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"]
|
||||||
|
id: string
|
||||||
|
externalId: string
|
||||||
|
meta: ScimMeta & {
|
||||||
|
resourceType: "User"
|
||||||
|
}
|
||||||
|
userName: string
|
||||||
|
displayName?: string
|
||||||
|
name: {
|
||||||
|
formatted: string
|
||||||
|
familyName: string
|
||||||
|
givenName: string
|
||||||
|
}
|
||||||
|
active: BooleanString
|
||||||
|
emails: [
|
||||||
|
{
|
||||||
|
value: string
|
||||||
|
type: "work"
|
||||||
|
primary: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScimCreateUserRequest {
|
||||||
|
schemas: [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||||
|
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
|
||||||
|
]
|
||||||
|
externalId: string
|
||||||
|
userName: string
|
||||||
|
active: BooleanString
|
||||||
|
emails: [
|
||||||
|
{
|
||||||
|
primary: true
|
||||||
|
type: "work"
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
]
|
||||||
|
meta: {
|
||||||
|
resourceType: "User"
|
||||||
|
}
|
||||||
|
displayName?: string
|
||||||
|
name: {
|
||||||
|
formatted: string
|
||||||
|
familyName: string
|
||||||
|
givenName: string
|
||||||
|
}
|
||||||
|
roles: []
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScimUserListResponse
|
||||||
|
extends ScimListResponse<ScimUserResponse> {}
|
|
@ -22,6 +22,6 @@ export interface PaginationRequest extends BasicPaginationRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginationResponse {
|
export interface PaginationResponse {
|
||||||
bookmark: string
|
bookmark: string | undefined
|
||||||
hasNextPage: boolean
|
hasNextPage: boolean
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { Document } from "../document"
|
import { Document } from "../document"
|
||||||
|
|
||||||
export interface Config extends Document {
|
export interface Config<T = any> extends Document {
|
||||||
type: ConfigType
|
type: ConfigType
|
||||||
config: any
|
config: T
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SMTPInnerConfig {
|
export interface SMTPInnerConfig {
|
||||||
|
@ -18,9 +18,7 @@ export interface SMTPInnerConfig {
|
||||||
connectionTimeout?: any
|
connectionTimeout?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SMTPConfig extends Config {
|
export interface SMTPConfig extends Config<SMTPInnerConfig> {}
|
||||||
config: SMTPInnerConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accessible only via pro.
|
* Accessible only via pro.
|
||||||
|
@ -50,9 +48,7 @@ export interface SettingsInnerConfig {
|
||||||
isSSOEnforced?: boolean
|
isSSOEnforced?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingsConfig extends Config {
|
export interface SettingsConfig extends Config<SettingsInnerConfig> {}
|
||||||
config: SettingsInnerConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SSOConfigType = ConfigType.GOOGLE | ConfigType.OIDC
|
export type SSOConfigType = ConfigType.GOOGLE | ConfigType.OIDC
|
||||||
export type SSOConfig = GoogleInnerConfig | OIDCInnerConfig
|
export type SSOConfig = GoogleInnerConfig | OIDCInnerConfig
|
||||||
|
@ -67,9 +63,7 @@ export interface GoogleInnerConfig {
|
||||||
callbackURL?: string
|
callbackURL?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GoogleConfig extends Config {
|
export interface GoogleConfig extends Config<GoogleInnerConfig> {}
|
||||||
config: GoogleInnerConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OIDCStrategyConfiguration {
|
export interface OIDCStrategyConfiguration {
|
||||||
issuer: string
|
issuer: string
|
||||||
|
@ -96,9 +90,7 @@ export interface OIDCInnerConfig {
|
||||||
scopes: string[]
|
scopes: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OIDCConfig extends Config {
|
export interface OIDCConfig extends Config<OIDCConfigs> {}
|
||||||
config: OIDCConfigs
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OIDCWellKnownConfig {
|
export interface OIDCWellKnownConfig {
|
||||||
issuer: string
|
issuer: string
|
||||||
|
@ -107,6 +99,12 @@ export interface OIDCWellKnownConfig {
|
||||||
userinfo_endpoint: string
|
userinfo_endpoint: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SCIMInnerConfig {
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SCIMConfig extends Config<SCIMInnerConfig> {}
|
||||||
|
|
||||||
export const isSettingsConfig = (config: Config): config is SettingsConfig =>
|
export const isSettingsConfig = (config: Config): config is SettingsConfig =>
|
||||||
config.type === ConfigType.SETTINGS
|
config.type === ConfigType.SETTINGS
|
||||||
|
|
||||||
|
@ -119,6 +117,9 @@ export const isGoogleConfig = (config: Config): config is GoogleConfig =>
|
||||||
export const isOIDCConfig = (config: Config): config is OIDCConfig =>
|
export const isOIDCConfig = (config: Config): config is OIDCConfig =>
|
||||||
config.type === ConfigType.OIDC
|
config.type === ConfigType.OIDC
|
||||||
|
|
||||||
|
export const isSCIMConfig = (config: Config): config is SCIMConfig =>
|
||||||
|
config.type === ConfigType.SCIM
|
||||||
|
|
||||||
export enum ConfigType {
|
export enum ConfigType {
|
||||||
SETTINGS = "settings",
|
SETTINGS = "settings",
|
||||||
ACCOUNT = "account",
|
ACCOUNT = "account",
|
||||||
|
@ -126,4 +127,5 @@ export enum ConfigType {
|
||||||
GOOGLE = "google",
|
GOOGLE = "google",
|
||||||
OIDC = "oidc",
|
OIDC = "oidc",
|
||||||
OIDC_LOGOS = "logos_oidc",
|
OIDC_LOGOS = "logos_oidc",
|
||||||
|
SCIM = "scim",
|
||||||
}
|
}
|
||||||
|
|
|
@ -53,6 +53,12 @@ export interface User extends Document {
|
||||||
dayPassRecordedAt?: string
|
dayPassRecordedAt?: string
|
||||||
userGroups?: string[]
|
userGroups?: string[]
|
||||||
onboardedAt?: string
|
onboardedAt?: string
|
||||||
|
scimInfo?: {
|
||||||
|
isSync: boolean
|
||||||
|
userName: string
|
||||||
|
externalId: string
|
||||||
|
displayName?: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum UserStatus {
|
export enum UserStatus {
|
||||||
|
|
|
@ -7,6 +7,10 @@ export interface UserGroup extends Document {
|
||||||
users?: GroupUser[]
|
users?: GroupUser[]
|
||||||
roles?: UserGroupRoles
|
roles?: UserGroupRoles
|
||||||
createdAt?: number
|
createdAt?: number
|
||||||
|
scimInfo?: {
|
||||||
|
externalId: string
|
||||||
|
isSync: boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupUser {
|
export interface GroupUser {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { Writable } from "stream"
|
||||||
export enum SearchIndex {
|
export enum SearchIndex {
|
||||||
ROWS = "rows",
|
ROWS = "rows",
|
||||||
AUDIT = "audit",
|
AUDIT = "audit",
|
||||||
|
USER = "user",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PouchOptions = {
|
export type PouchOptions = {
|
||||||
|
|
|
@ -194,9 +194,9 @@ export enum Event {
|
||||||
// a user facing event or not.
|
// a user facing event or not.
|
||||||
export const AuditedEventFriendlyName: Record<Event, string | undefined> = {
|
export const AuditedEventFriendlyName: Record<Event, string | undefined> = {
|
||||||
// USER
|
// USER
|
||||||
[Event.USER_CREATED]: `User "{{ email }}" created`,
|
[Event.USER_CREATED]: `User "{{ email }}" created{{#if viaScim}} via SCIM{{/if}}`,
|
||||||
[Event.USER_UPDATED]: `User "{{ email }}" updated`,
|
[Event.USER_UPDATED]: `User "{{ email }}" updated{{#if viaScim}} via SCIM{{/if}}`,
|
||||||
[Event.USER_DELETED]: `User "{{ email }}" deleted`,
|
[Event.USER_DELETED]: `User "{{ email }}" deleted{{#if viaScim}} via SCIM{{/if}}`,
|
||||||
[Event.USER_PERMISSION_ADMIN_ASSIGNED]: `User "{{ email }}" admin role assigned`,
|
[Event.USER_PERMISSION_ADMIN_ASSIGNED]: `User "{{ email }}" admin role assigned`,
|
||||||
[Event.USER_PERMISSION_ADMIN_REMOVED]: `User "{{ email }}" admin role removed`,
|
[Event.USER_PERMISSION_ADMIN_REMOVED]: `User "{{ email }}" admin role removed`,
|
||||||
[Event.USER_PERMISSION_BUILDER_ASSIGNED]: `User "{{ email }}" builder role assigned`,
|
[Event.USER_PERMISSION_BUILDER_ASSIGNED]: `User "{{ email }}" builder role assigned`,
|
||||||
|
@ -206,11 +206,11 @@ export const AuditedEventFriendlyName: Record<Event, string | undefined> = {
|
||||||
[Event.USER_PASSWORD_UPDATED]: `User "{{ email }}" password updated`,
|
[Event.USER_PASSWORD_UPDATED]: `User "{{ email }}" password updated`,
|
||||||
[Event.USER_PASSWORD_RESET_REQUESTED]: `User "{{ email }}" password reset requested`,
|
[Event.USER_PASSWORD_RESET_REQUESTED]: `User "{{ email }}" password reset requested`,
|
||||||
[Event.USER_PASSWORD_RESET]: `User "{{ email }}" password reset`,
|
[Event.USER_PASSWORD_RESET]: `User "{{ email }}" password reset`,
|
||||||
[Event.USER_GROUP_CREATED]: `User group "{{ name }}" created`,
|
[Event.USER_GROUP_CREATED]: `User group "{{ name }}" created{{#if viaScim}} via SCIM{{/if}}`,
|
||||||
[Event.USER_GROUP_UPDATED]: `User group "{{ name }}" updated`,
|
[Event.USER_GROUP_UPDATED]: `User group "{{ name }}" updated{{#if viaScim}} via SCIM{{/if}}`,
|
||||||
[Event.USER_GROUP_DELETED]: `User group "{{ name }}" deleted`,
|
[Event.USER_GROUP_DELETED]: `User group "{{ name }}" deleted{{#if viaScim}} via SCIM{{/if}}`,
|
||||||
[Event.USER_GROUP_USERS_ADDED]: `User group "{{ name }}" {{ count }} users added`,
|
[Event.USER_GROUP_USERS_ADDED]: `User group "{{ name }}" {{ count }} users added{{#if viaScim}} via SCIM{{/if}}`,
|
||||||
[Event.USER_GROUP_USERS_REMOVED]: `User group "{{ name }}" {{ count }} users removed`,
|
[Event.USER_GROUP_USERS_REMOVED]: `User group "{{ name }}" {{ count }} users removed{{#if viaScim}} via SCIM{{/if}}`,
|
||||||
[Event.USER_GROUP_PERMISSIONS_EDITED]: `User group "{{ name }}" permissions edited`,
|
[Event.USER_GROUP_PERMISSIONS_EDITED]: `User group "{{ name }}" permissions edited`,
|
||||||
[Event.USER_PASSWORD_FORCE_RESET]: undefined,
|
[Event.USER_PASSWORD_FORCE_RESET]: undefined,
|
||||||
[Event.USER_GROUP_ONBOARDING]: undefined,
|
[Event.USER_GROUP_ONBOARDING]: undefined,
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { BaseEvent } from "./event"
|
||||||
|
|
||||||
export interface UserCreatedEvent extends BaseEvent {
|
export interface UserCreatedEvent extends BaseEvent {
|
||||||
userId: string
|
userId: string
|
||||||
|
viaScim?: boolean
|
||||||
audited: {
|
audited: {
|
||||||
email: string
|
email: string
|
||||||
}
|
}
|
||||||
|
@ -9,6 +10,7 @@ export interface UserCreatedEvent extends BaseEvent {
|
||||||
|
|
||||||
export interface UserUpdatedEvent extends BaseEvent {
|
export interface UserUpdatedEvent extends BaseEvent {
|
||||||
userId: string
|
userId: string
|
||||||
|
viaScim?: boolean
|
||||||
audited: {
|
audited: {
|
||||||
email: string
|
email: string
|
||||||
}
|
}
|
||||||
|
@ -16,6 +18,7 @@ export interface UserUpdatedEvent extends BaseEvent {
|
||||||
|
|
||||||
export interface UserDeletedEvent extends BaseEvent {
|
export interface UserDeletedEvent extends BaseEvent {
|
||||||
userId: string
|
userId: string
|
||||||
|
viaScim?: boolean
|
||||||
audited: {
|
audited: {
|
||||||
email: string
|
email: string
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { BaseEvent } from "./event"
|
||||||
|
|
||||||
export interface GroupCreatedEvent extends BaseEvent {
|
export interface GroupCreatedEvent extends BaseEvent {
|
||||||
groupId: string
|
groupId: string
|
||||||
|
viaScim?: boolean
|
||||||
audited: {
|
audited: {
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
@ -9,6 +10,7 @@ export interface GroupCreatedEvent extends BaseEvent {
|
||||||
|
|
||||||
export interface GroupUpdatedEvent extends BaseEvent {
|
export interface GroupUpdatedEvent extends BaseEvent {
|
||||||
groupId: string
|
groupId: string
|
||||||
|
viaScim?: boolean
|
||||||
audited: {
|
audited: {
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
@ -16,6 +18,7 @@ export interface GroupUpdatedEvent extends BaseEvent {
|
||||||
|
|
||||||
export interface GroupDeletedEvent extends BaseEvent {
|
export interface GroupDeletedEvent extends BaseEvent {
|
||||||
groupId: string
|
groupId: string
|
||||||
|
viaScim?: boolean
|
||||||
audited: {
|
audited: {
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
@ -24,6 +27,7 @@ export interface GroupDeletedEvent extends BaseEvent {
|
||||||
export interface GroupUsersAddedEvent extends BaseEvent {
|
export interface GroupUsersAddedEvent extends BaseEvent {
|
||||||
count: number
|
count: number
|
||||||
groupId: string
|
groupId: string
|
||||||
|
viaScim?: boolean
|
||||||
audited: {
|
audited: {
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
@ -32,6 +36,7 @@ export interface GroupUsersAddedEvent extends BaseEvent {
|
||||||
export interface GroupUsersDeletedEvent extends BaseEvent {
|
export interface GroupUsersDeletedEvent extends BaseEvent {
|
||||||
count: number
|
count: number
|
||||||
groupId: string
|
groupId: string
|
||||||
|
viaScim?: boolean
|
||||||
audited: {
|
audited: {
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,4 +5,5 @@ export enum Feature {
|
||||||
AUDIT_LOGS = "auditLogs",
|
AUDIT_LOGS = "auditLogs",
|
||||||
ENFORCEABLE_SSO = "enforceableSSO",
|
ENFORCEABLE_SSO = "enforceableSSO",
|
||||||
BRANDING = "branding",
|
BRANDING = "branding",
|
||||||
|
SCIM = "scim",
|
||||||
}
|
}
|
||||||
|
|
|
@ -487,6 +487,11 @@ escalade@^3.1.1:
|
||||||
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
|
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
|
||||||
integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
|
integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
|
||||||
|
|
||||||
|
fast-deep-equal@3.1.3:
|
||||||
|
version "3.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
|
||||||
|
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
|
||||||
|
|
||||||
follow-redirects@^1.15.0:
|
follow-redirects@^1.15.0:
|
||||||
version "1.15.2"
|
version "1.15.2"
|
||||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
|
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
|
||||||
|
@ -743,6 +748,19 @@ rxjs@^7.0.0:
|
||||||
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
|
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
|
||||||
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
||||||
|
|
||||||
|
scim-patch@^0.7.0:
|
||||||
|
version "0.7.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/scim-patch/-/scim-patch-0.7.0.tgz#3f6d94256c07be415a74a49c0ff48dc91e4e0219"
|
||||||
|
integrity sha512-wXKcsZl+aLfE0yId7MjiOd91v8as6dEYLFvm1gGu3yJxSPhl1Fl3vWiNN4V3D68UKpqO/umK5rwWc8wGpBaOHw==
|
||||||
|
dependencies:
|
||||||
|
fast-deep-equal "3.1.3"
|
||||||
|
scim2-parse-filter "0.2.8"
|
||||||
|
|
||||||
|
scim2-parse-filter@0.2.8:
|
||||||
|
version "0.2.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/scim2-parse-filter/-/scim2-parse-filter-0.2.8.tgz#12e836514b9a55ae51218dd6e7fbea91daccfa4d"
|
||||||
|
integrity sha512-1V+6FIMIiP+gDiFkC3dIw86KfoXhnQRXhfPaiQImeeFukpLtEkTtYq/Vmy1yDgHQcIHQxQQqOWyGLKX0FTvvaA==
|
||||||
|
|
||||||
setprototypeof@1.2.0:
|
setprototypeof@1.2.0:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
|
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
|
||||||
|
|
|
@ -79,6 +79,7 @@
|
||||||
"@types/jsonwebtoken": "8.5.1",
|
"@types/jsonwebtoken": "8.5.1",
|
||||||
"@types/koa": "2.13.4",
|
"@types/koa": "2.13.4",
|
||||||
"@types/koa__router": "8.0.8",
|
"@types/koa__router": "8.0.8",
|
||||||
|
"@types/lodash": "^4.14.191",
|
||||||
"@types/node": "14.18.20",
|
"@types/node": "14.18.20",
|
||||||
"@types/node-fetch": "2.6.1",
|
"@types/node-fetch": "2.6.1",
|
||||||
"@types/pouchdb": "6.4.0",
|
"@types/pouchdb": "6.4.0",
|
||||||
|
@ -89,6 +90,7 @@
|
||||||
"copyfiles": "2.4.1",
|
"copyfiles": "2.4.1",
|
||||||
"eslint": "6.8.0",
|
"eslint": "6.8.0",
|
||||||
"jest": "28.1.1",
|
"jest": "28.1.1",
|
||||||
|
"lodash": "4.17.21",
|
||||||
"nodemon": "2.0.15",
|
"nodemon": "2.0.15",
|
||||||
"pouchdb-adapter-memory": "7.2.2",
|
"pouchdb-adapter-memory": "7.2.2",
|
||||||
"prettier": "2.3.1",
|
"prettier": "2.3.1",
|
||||||
|
|
|
@ -177,7 +177,7 @@ export const destroy = async (ctx: any) => {
|
||||||
ctx.throw(400, "Unable to delete self.")
|
ctx.throw(400, "Unable to delete self.")
|
||||||
}
|
}
|
||||||
|
|
||||||
await userSdk.destroy(id, ctx.user)
|
await userSdk.destroy(id)
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
message: `User ${id} deleted.`,
|
message: `User ${id} deleted.`,
|
||||||
|
@ -197,7 +197,7 @@ export const search = async (ctx: any) => {
|
||||||
if (body.paginated === false) {
|
if (body.paginated === false) {
|
||||||
await getAppUsers(ctx)
|
await getAppUsers(ctx)
|
||||||
} else {
|
} else {
|
||||||
const paginated = await userSdk.paginatedUsers(body)
|
const paginated = await userSdk.core.paginatedUsers(body)
|
||||||
// user hashed password shouldn't ever be returned
|
// user hashed password shouldn't ever be returned
|
||||||
for (let user of paginated.data) {
|
for (let user of paginated.data) {
|
||||||
if (user) {
|
if (user) {
|
||||||
|
|
|
@ -58,6 +58,13 @@ function oidcValidation() {
|
||||||
}).unknown(true)
|
}).unknown(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scimValidation() {
|
||||||
|
// prettier-ignore
|
||||||
|
return Joi.object({
|
||||||
|
enabled: Joi.boolean().required(),
|
||||||
|
}).unknown(true)
|
||||||
|
}
|
||||||
|
|
||||||
function buildConfigSaveValidation() {
|
function buildConfigSaveValidation() {
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
return auth.joiValidator.body(Joi.object({
|
return auth.joiValidator.body(Joi.object({
|
||||||
|
@ -74,7 +81,8 @@ function buildConfigSaveValidation() {
|
||||||
{ is: ConfigType.SETTINGS, then: settingValidation() },
|
{ is: ConfigType.SETTINGS, then: settingValidation() },
|
||||||
{ is: ConfigType.ACCOUNT, then: Joi.object().unknown(true) },
|
{ is: ConfigType.ACCOUNT, then: Joi.object().unknown(true) },
|
||||||
{ is: ConfigType.GOOGLE, then: googleValidation() },
|
{ is: ConfigType.GOOGLE, then: googleValidation() },
|
||||||
{ is: ConfigType.OIDC, then: oidcValidation() }
|
{ is: ConfigType.OIDC, then: oidcValidation() },
|
||||||
|
{ is: ConfigType.SCIM, then: scimValidation() }
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
}).required().unknown(true),
|
}).required().unknown(true),
|
||||||
|
|
|
@ -0,0 +1,892 @@
|
||||||
|
import tk from "timekeeper"
|
||||||
|
import _ from "lodash"
|
||||||
|
import { mocks, structures } from "@budibase/backend-core/tests"
|
||||||
|
import {
|
||||||
|
ScimGroupResponse,
|
||||||
|
ScimUpdateRequest,
|
||||||
|
ScimUserResponse,
|
||||||
|
} from "@budibase/types"
|
||||||
|
import { TestConfiguration } from "../../../../tests"
|
||||||
|
import { events } from "@budibase/backend-core"
|
||||||
|
|
||||||
|
mocks.licenses.useScimIntegration()
|
||||||
|
|
||||||
|
describe("scim", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.resetAllMocks()
|
||||||
|
tk.freeze(mocks.date.MOCK_DATE)
|
||||||
|
mocks.licenses.useScimIntegration()
|
||||||
|
|
||||||
|
await config.setSCIMConfig(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
const config = new TestConfiguration()
|
||||||
|
|
||||||
|
const unauthorisedTests = (fn: (...params: any) => Promise<any>) => {
|
||||||
|
describe("unauthorised calls", () => {
|
||||||
|
it("unauthorised calls are not allowed", async () => {
|
||||||
|
const response = await fn(...Array(fn.length - 1).fill({}), {
|
||||||
|
setHeaders: false,
|
||||||
|
expect: 403,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response).toEqual({ message: "Tenant id not set", status: 403 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("cannot be called when feature is disabled", async () => {
|
||||||
|
mocks.licenses.useCloudFree()
|
||||||
|
const response = await fn(...Array(fn.length - 1).fill({}), {
|
||||||
|
expect: 400,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response).toEqual({
|
||||||
|
error: {
|
||||||
|
code: "feature_disabled",
|
||||||
|
featureName: "scim",
|
||||||
|
},
|
||||||
|
message: "scim is not currently enabled",
|
||||||
|
status: 400,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("cannot be called when feature is enabled but the config disabled", async () => {
|
||||||
|
await config.setSCIMConfig(false)
|
||||||
|
const response = await fn(...Array(fn.length - 1).fill({}), {
|
||||||
|
expect: 400,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response).toEqual({
|
||||||
|
error: {
|
||||||
|
code: "feature_disabled",
|
||||||
|
featureName: "scim",
|
||||||
|
},
|
||||||
|
message: "scim is not currently enabled",
|
||||||
|
status: 400,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await config.beforeAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await config.afterAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("/api/global/scim/v2/users", () => {
|
||||||
|
describe("GET /api/global/scim/v2/users", () => {
|
||||||
|
const getScimUsers = config.api.scimUsersAPI.get
|
||||||
|
|
||||||
|
unauthorisedTests(getScimUsers)
|
||||||
|
|
||||||
|
describe("no users exist", () => {
|
||||||
|
it("should retrieve empty list", async () => {
|
||||||
|
const response = await getScimUsers()
|
||||||
|
|
||||||
|
expect(response).toEqual({
|
||||||
|
Resources: [],
|
||||||
|
itemsPerPage: 20,
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||||
|
startIndex: 1,
|
||||||
|
totalResults: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("multiple users exist", () => {
|
||||||
|
const userCount = 30
|
||||||
|
let users: ScimUserResponse[]
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
users = []
|
||||||
|
|
||||||
|
for (let i = 0; i < userCount; i++) {
|
||||||
|
const body = structures.scim.createUserRequest()
|
||||||
|
users.push(await config.api.scimUsersAPI.post({ body }))
|
||||||
|
}
|
||||||
|
|
||||||
|
users = users.sort((a, b) => (a.id > b.id ? 1 : -1))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("fetches full first page", async () => {
|
||||||
|
const response = await getScimUsers()
|
||||||
|
|
||||||
|
expect(response).toEqual({
|
||||||
|
Resources: expect.arrayContaining(users.slice(0, 20)),
|
||||||
|
itemsPerPage: 20,
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||||
|
startIndex: 1,
|
||||||
|
totalResults: userCount,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("fetches second page", async () => {
|
||||||
|
const response = await getScimUsers({ params: { startIndex: 20 } })
|
||||||
|
|
||||||
|
expect(response).toEqual({
|
||||||
|
Resources: users.slice(20),
|
||||||
|
itemsPerPage: 20,
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||||
|
startIndex: 21,
|
||||||
|
totalResults: userCount,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can filter by user name", async () => {
|
||||||
|
const userToFetch = _.sample(users)
|
||||||
|
|
||||||
|
const response = await getScimUsers({
|
||||||
|
params: {
|
||||||
|
filter: encodeURI(`userName eq "${userToFetch?.userName}"`),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response).toEqual({
|
||||||
|
Resources: [userToFetch],
|
||||||
|
itemsPerPage: 20,
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||||
|
startIndex: 1,
|
||||||
|
totalResults: 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can filter by external id", async () => {
|
||||||
|
const userToFetch = _.sample(users)
|
||||||
|
|
||||||
|
const response = await getScimUsers({
|
||||||
|
params: {
|
||||||
|
filter: encodeURI(`externalId eq "${userToFetch?.externalId}"`),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response).toEqual({
|
||||||
|
Resources: [userToFetch],
|
||||||
|
itemsPerPage: 20,
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||||
|
startIndex: 1,
|
||||||
|
totalResults: 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can filter by email", async () => {
|
||||||
|
const userToFetch = _.sample(users)
|
||||||
|
|
||||||
|
const response = await getScimUsers({
|
||||||
|
params: {
|
||||||
|
filter: encodeURI(
|
||||||
|
`emails[type eq "work"].value eq "${userToFetch?.emails[0].value}"`
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response).toEqual({
|
||||||
|
Resources: [userToFetch],
|
||||||
|
itemsPerPage: 20,
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||||
|
startIndex: 1,
|
||||||
|
totalResults: 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("POST /api/global/scim/v2/users", () => {
|
||||||
|
const postScimUser = config.api.scimUsersAPI.post
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await config.useNewTenant()
|
||||||
|
})
|
||||||
|
|
||||||
|
unauthorisedTests(postScimUser)
|
||||||
|
|
||||||
|
describe("no users exist", () => {
|
||||||
|
it("a new user can be created and persisted", async () => {
|
||||||
|
const mockedTime = new Date(structures.generator.timestamp())
|
||||||
|
tk.freeze(mockedTime)
|
||||||
|
|
||||||
|
const userData = {
|
||||||
|
externalId: structures.uuid(),
|
||||||
|
email: structures.generator.email(),
|
||||||
|
firstName: structures.generator.first(),
|
||||||
|
lastName: structures.generator.last(),
|
||||||
|
username: structures.generator.name(),
|
||||||
|
}
|
||||||
|
const body = structures.scim.createUserRequest(userData)
|
||||||
|
|
||||||
|
const response = await postScimUser({ body })
|
||||||
|
|
||||||
|
const expectedScimUser = {
|
||||||
|
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||||
|
id: expect.any(String),
|
||||||
|
externalId: userData.externalId,
|
||||||
|
meta: {
|
||||||
|
resourceType: "User",
|
||||||
|
created: mockedTime.toISOString(),
|
||||||
|
lastModified: mockedTime.toISOString(),
|
||||||
|
},
|
||||||
|
userName: userData.username,
|
||||||
|
name: {
|
||||||
|
formatted: `${userData.firstName} ${userData.lastName}`,
|
||||||
|
familyName: userData.lastName,
|
||||||
|
givenName: userData.firstName,
|
||||||
|
},
|
||||||
|
active: true,
|
||||||
|
emails: [
|
||||||
|
{
|
||||||
|
value: userData.email,
|
||||||
|
type: "work",
|
||||||
|
primary: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
expect(response).toEqual(expectedScimUser)
|
||||||
|
|
||||||
|
const persistedUsers = await config.api.scimUsersAPI.get()
|
||||||
|
expect(persistedUsers).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
totalResults: 1,
|
||||||
|
Resources: [expectedScimUser],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("an event is dispatched", async () => {
|
||||||
|
const body = structures.scim.createUserRequest()
|
||||||
|
|
||||||
|
await postScimUser({ body })
|
||||||
|
|
||||||
|
expect(events.user.created).toBeCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("GET /api/global/scim/v2/users/:id", () => {
|
||||||
|
let user: ScimUserResponse
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const body = structures.scim.createUserRequest()
|
||||||
|
|
||||||
|
user = await config.api.scimUsersAPI.post({ body })
|
||||||
|
})
|
||||||
|
|
||||||
|
const findScimUser = config.api.scimUsersAPI.find
|
||||||
|
|
||||||
|
unauthorisedTests(findScimUser)
|
||||||
|
|
||||||
|
it("should return existing user", async () => {
|
||||||
|
const response = await findScimUser(user.id)
|
||||||
|
|
||||||
|
expect(response).toEqual(user)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return 404 when requesting unexisting user id", async () => {
|
||||||
|
const response = await findScimUser(structures.uuid(), { expect: 404 })
|
||||||
|
|
||||||
|
expect(response).toEqual({
|
||||||
|
message: "missing",
|
||||||
|
status: 404,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("PATCH /api/global/scim/v2/users/:id", () => {
|
||||||
|
const patchScimUser = config.api.scimUsersAPI.patch
|
||||||
|
|
||||||
|
let user: ScimUserResponse
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const body = structures.scim.createUserRequest()
|
||||||
|
|
||||||
|
user = await config.api.scimUsersAPI.post({ body })
|
||||||
|
})
|
||||||
|
|
||||||
|
unauthorisedTests(patchScimUser)
|
||||||
|
|
||||||
|
it("an existing user can be updated", async () => {
|
||||||
|
const newUserName = structures.generator.name()
|
||||||
|
const newFamilyName = structures.generator.last()
|
||||||
|
const body: ScimUpdateRequest = {
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||||
|
Operations: [
|
||||||
|
{
|
||||||
|
op: "Replace",
|
||||||
|
path: "userName",
|
||||||
|
value: newUserName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
op: "Replace",
|
||||||
|
path: "name.familyName",
|
||||||
|
value: newFamilyName,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await patchScimUser({ id: user.id, body })
|
||||||
|
|
||||||
|
const expectedScimUser: ScimUserResponse = {
|
||||||
|
...user,
|
||||||
|
userName: newUserName,
|
||||||
|
name: {
|
||||||
|
...user.name,
|
||||||
|
familyName: newFamilyName,
|
||||||
|
formatted: `${user.name.givenName} ${newFamilyName}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
expect(response).toEqual(expectedScimUser)
|
||||||
|
|
||||||
|
const persistedUser = await config.api.scimUsersAPI.find(user.id)
|
||||||
|
expect(persistedUser).toEqual(expectedScimUser)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([false, "false", "False"])(
|
||||||
|
"can deactive an active user (sending %s)",
|
||||||
|
async activeValue => {
|
||||||
|
const body: ScimUpdateRequest = {
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||||
|
Operations: [{ op: "Replace", path: "active", value: activeValue }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await patchScimUser({ id: user.id, body })
|
||||||
|
|
||||||
|
const expectedScimUser: ScimUserResponse = {
|
||||||
|
...user,
|
||||||
|
active: false,
|
||||||
|
}
|
||||||
|
expect(response).toEqual(expectedScimUser)
|
||||||
|
|
||||||
|
const persistedUser = await config.api.scimUsersAPI.find(user.id)
|
||||||
|
expect(persistedUser).toEqual(expectedScimUser)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
it.each([true, "true", "True"])(
|
||||||
|
"can activate an inactive user (sending %s)",
|
||||||
|
async activeValue => {
|
||||||
|
// Deactivate user
|
||||||
|
await patchScimUser({
|
||||||
|
id: user.id,
|
||||||
|
body: {
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||||
|
Operations: [{ op: "Replace", path: "active", value: true }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const body: ScimUpdateRequest = {
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||||
|
Operations: [{ op: "Replace", path: "active", value: activeValue }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await patchScimUser({ id: user.id, body })
|
||||||
|
|
||||||
|
const expectedScimUser: ScimUserResponse = {
|
||||||
|
...user,
|
||||||
|
active: true,
|
||||||
|
}
|
||||||
|
expect(response).toEqual(expectedScimUser)
|
||||||
|
|
||||||
|
const persistedUser = await config.api.scimUsersAPI.find(user.id)
|
||||||
|
expect(persistedUser).toEqual(expectedScimUser)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
it("supports updating unmapped fields", async () => {
|
||||||
|
const body: ScimUpdateRequest = {
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||||
|
Operations: [
|
||||||
|
{
|
||||||
|
op: "Add",
|
||||||
|
path: "preferredLanguage",
|
||||||
|
value: structures.generator.letter(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await patchScimUser({ id: user.id, body })
|
||||||
|
|
||||||
|
const expectedScimUser: ScimUserResponse = {
|
||||||
|
...user,
|
||||||
|
}
|
||||||
|
expect(response).toEqual(expectedScimUser)
|
||||||
|
|
||||||
|
const persistedUser = await config.api.scimUsersAPI.find(user.id)
|
||||||
|
expect(persistedUser).toEqual(expectedScimUser)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("an event is dispatched", async () => {
|
||||||
|
const body: ScimUpdateRequest = {
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||||
|
Operations: [
|
||||||
|
{
|
||||||
|
op: "Replace",
|
||||||
|
path: "userName",
|
||||||
|
value: structures.generator.name(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
await patchScimUser({ id: user.id, body })
|
||||||
|
|
||||||
|
expect(events.user.updated).toBeCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("DELETE /api/global/scim/v2/users/:id", () => {
|
||||||
|
const deleteScimUser = config.api.scimUsersAPI.delete
|
||||||
|
|
||||||
|
let user: ScimUserResponse
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const body = structures.scim.createUserRequest()
|
||||||
|
|
||||||
|
user = await config.api.scimUsersAPI.post({ body })
|
||||||
|
})
|
||||||
|
|
||||||
|
unauthorisedTests(deleteScimUser)
|
||||||
|
|
||||||
|
it("an existing user can be deleted", async () => {
|
||||||
|
const response = await deleteScimUser(user.id, { expect: 204 })
|
||||||
|
|
||||||
|
expect(response).toEqual({})
|
||||||
|
|
||||||
|
await config.api.scimUsersAPI.find(user.id, { expect: 404 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("an non existing user can not be deleted", async () => {
|
||||||
|
await deleteScimUser(structures.uuid(), { expect: 404 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("an event is dispatched", async () => {
|
||||||
|
await deleteScimUser(user.id, { expect: 204 })
|
||||||
|
|
||||||
|
expect(events.user.deleted).toBeCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("/api/global/scim/v2/groups", () => {
|
||||||
|
describe("GET /api/global/scim/v2/groups", () => {
|
||||||
|
const getScimGroups = config.api.scimGroupsAPI.get
|
||||||
|
|
||||||
|
unauthorisedTests(getScimGroups)
|
||||||
|
|
||||||
|
describe("no groups exist", () => {
|
||||||
|
it("should retrieve empty list", async () => {
|
||||||
|
const response = await getScimGroups()
|
||||||
|
|
||||||
|
expect(response).toEqual({
|
||||||
|
Resources: [],
|
||||||
|
itemsPerPage: 0,
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||||
|
startIndex: 1,
|
||||||
|
totalResults: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("multiple groups exist", () => {
|
||||||
|
const groupCount = 25
|
||||||
|
let groups: ScimGroupResponse[]
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
groups = []
|
||||||
|
|
||||||
|
for (let i = 0; i < groupCount; i++) {
|
||||||
|
const body = structures.scim.createGroupRequest()
|
||||||
|
groups.push(await config.api.scimGroupsAPI.post({ body }))
|
||||||
|
}
|
||||||
|
|
||||||
|
groups = groups.sort((a, b) => (a.id > b.id ? 1 : -1))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can fetch all groups without filters", async () => {
|
||||||
|
const response = await getScimGroups()
|
||||||
|
|
||||||
|
expect(response).toEqual({
|
||||||
|
Resources: expect.arrayContaining(groups),
|
||||||
|
itemsPerPage: 25,
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||||
|
startIndex: 1,
|
||||||
|
totalResults: groupCount,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("POST /api/global/scim/v2/groups", () => {
|
||||||
|
const postScimGroup = config.api.scimGroupsAPI.post
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await config.useNewTenant()
|
||||||
|
})
|
||||||
|
|
||||||
|
unauthorisedTests(postScimGroup)
|
||||||
|
|
||||||
|
describe("no groups exist", () => {
|
||||||
|
it("a new group can be created and persisted", async () => {
|
||||||
|
const mockedTime = new Date(structures.generator.timestamp())
|
||||||
|
tk.freeze(mockedTime)
|
||||||
|
|
||||||
|
const groupData = {
|
||||||
|
externalId: structures.uuid(),
|
||||||
|
displayName: structures.generator.word(),
|
||||||
|
}
|
||||||
|
const body = structures.scim.createGroupRequest(groupData)
|
||||||
|
|
||||||
|
const response = await postScimGroup({ body })
|
||||||
|
|
||||||
|
const expectedScimGroup = {
|
||||||
|
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||||
|
id: expect.any(String),
|
||||||
|
externalId: groupData.externalId,
|
||||||
|
displayName: groupData.displayName,
|
||||||
|
meta: {
|
||||||
|
resourceType: "Group",
|
||||||
|
created: mockedTime.toISOString(),
|
||||||
|
lastModified: mockedTime.toISOString(),
|
||||||
|
},
|
||||||
|
members: [],
|
||||||
|
}
|
||||||
|
expect(response).toEqual(expectedScimGroup)
|
||||||
|
|
||||||
|
const persistedGroups = await config.api.scimGroupsAPI.get()
|
||||||
|
expect(persistedGroups).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
totalResults: 1,
|
||||||
|
Resources: [expectedScimGroup],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("GET /api/global/scim/v2/groups/:id", () => {
|
||||||
|
let group: ScimGroupResponse
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const body = structures.scim.createGroupRequest()
|
||||||
|
|
||||||
|
group = await config.api.scimGroupsAPI.post({ body })
|
||||||
|
})
|
||||||
|
|
||||||
|
const findScimGroup = config.api.scimGroupsAPI.find
|
||||||
|
|
||||||
|
unauthorisedTests(findScimGroup)
|
||||||
|
|
||||||
|
it("should return existing group", async () => {
|
||||||
|
const response = await findScimGroup(group.id)
|
||||||
|
|
||||||
|
expect(response).toEqual(group)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return 404 when requesting unexisting group id", async () => {
|
||||||
|
const response = await findScimGroup(structures.uuid(), { expect: 404 })
|
||||||
|
|
||||||
|
expect(response).toEqual({
|
||||||
|
message: "missing",
|
||||||
|
status: 404,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("DELETE /api/global/scim/v2/groups/:id", () => {
|
||||||
|
const deleteScimGroup = config.api.scimGroupsAPI.delete
|
||||||
|
|
||||||
|
let group: ScimGroupResponse
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const body = structures.scim.createGroupRequest()
|
||||||
|
|
||||||
|
group = await config.api.scimGroupsAPI.post({ body })
|
||||||
|
})
|
||||||
|
|
||||||
|
unauthorisedTests(deleteScimGroup)
|
||||||
|
|
||||||
|
it("an existing group can be deleted", async () => {
|
||||||
|
const response = await deleteScimGroup(group.id, { expect: 204 })
|
||||||
|
|
||||||
|
expect(response).toEqual({})
|
||||||
|
|
||||||
|
await config.api.scimGroupsAPI.find(group.id, { expect: 404 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("an non existing group can not be deleted", async () => {
|
||||||
|
await deleteScimGroup(structures.uuid(), { expect: 404 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("PATCH /api/global/scim/v2/groups/:id", () => {
|
||||||
|
const patchScimGroup = config.api.scimGroupsAPI.patch
|
||||||
|
|
||||||
|
let group: ScimGroupResponse
|
||||||
|
let users: ScimUserResponse[]
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
users = []
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const body = structures.scim.createUserRequest()
|
||||||
|
users.push(await config.api.scimUsersAPI.post({ body }))
|
||||||
|
}
|
||||||
|
|
||||||
|
users = users.sort((a, b) => (a.id > b.id ? 1 : -1))
|
||||||
|
|
||||||
|
const body = structures.scim.createGroupRequest()
|
||||||
|
group = await config.api.scimGroupsAPI.post({ body })
|
||||||
|
})
|
||||||
|
|
||||||
|
unauthorisedTests(patchScimGroup)
|
||||||
|
|
||||||
|
it("an existing group can be updated", async () => {
|
||||||
|
const newDisplayName = structures.generator.word()
|
||||||
|
|
||||||
|
const body: ScimUpdateRequest = {
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||||
|
Operations: [
|
||||||
|
{
|
||||||
|
op: "Replace",
|
||||||
|
path: "displayName",
|
||||||
|
value: newDisplayName,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await patchScimGroup({ id: group.id, body })
|
||||||
|
|
||||||
|
const expectedScimGroup = {
|
||||||
|
...group,
|
||||||
|
displayName: newDisplayName,
|
||||||
|
}
|
||||||
|
expect(response).toEqual(expectedScimGroup)
|
||||||
|
|
||||||
|
const persistedGroup = await config.api.scimGroupsAPI.find(group.id)
|
||||||
|
expect(persistedGroup).toEqual(expectedScimGroup)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("adding users", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const body = structures.scim.createGroupRequest()
|
||||||
|
group = await config.api.scimGroupsAPI.post({ body })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("a new user can be added to an existing group", async () => {
|
||||||
|
const userToAdd = users[0]
|
||||||
|
|
||||||
|
const body: ScimUpdateRequest = {
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||||
|
Operations: [
|
||||||
|
{
|
||||||
|
op: "Add",
|
||||||
|
path: "members",
|
||||||
|
value: [
|
||||||
|
{
|
||||||
|
$ref: null,
|
||||||
|
value: userToAdd.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await patchScimGroup({ id: group.id, body })
|
||||||
|
|
||||||
|
const expectedScimGroup: ScimGroupResponse = {
|
||||||
|
...group,
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
value: userToAdd.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
expect(response).toEqual(expectedScimGroup)
|
||||||
|
|
||||||
|
const persistedGroup = await config.api.scimGroupsAPI.find(group.id)
|
||||||
|
expect(persistedGroup).toEqual(expectedScimGroup)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("multiple users can be added to an existing group", async () => {
|
||||||
|
const body: ScimUpdateRequest = {
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||||
|
Operations: [
|
||||||
|
{
|
||||||
|
op: "Add",
|
||||||
|
path: "members",
|
||||||
|
value: [
|
||||||
|
{
|
||||||
|
$ref: null,
|
||||||
|
value: users[1].id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$ref: null,
|
||||||
|
value: users[2].id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$ref: null,
|
||||||
|
value: users[3].id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await patchScimGroup({ id: group.id, body })
|
||||||
|
|
||||||
|
const expectedScimGroup: ScimGroupResponse = {
|
||||||
|
...group,
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
value: users[0].id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: users[1].id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: users[2].id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: users[3].id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
expect(response).toEqual(expectedScimGroup)
|
||||||
|
|
||||||
|
const persistedGroup = await config.api.scimGroupsAPI.find(group.id)
|
||||||
|
expect(persistedGroup).toEqual(expectedScimGroup)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("existing users can be removed from to an existing group", async () => {
|
||||||
|
const body: ScimUpdateRequest = {
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||||
|
Operations: [
|
||||||
|
{
|
||||||
|
op: "Remove",
|
||||||
|
path: "members",
|
||||||
|
value: [
|
||||||
|
{
|
||||||
|
$ref: null,
|
||||||
|
value: users[0].id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$ref: null,
|
||||||
|
value: users[2].id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await patchScimGroup({ id: group.id, body })
|
||||||
|
|
||||||
|
const expectedScimGroup: ScimGroupResponse = {
|
||||||
|
...group,
|
||||||
|
members: expect.arrayContaining([
|
||||||
|
{
|
||||||
|
value: users[1].id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: users[3].id,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
expect(response).toEqual(expectedScimGroup)
|
||||||
|
|
||||||
|
const persistedGroup = await config.api.scimGroupsAPI.find(group.id)
|
||||||
|
expect(persistedGroup).toEqual(expectedScimGroup)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("adding and removing can be added in a single operation", async () => {
|
||||||
|
const body: ScimUpdateRequest = {
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||||
|
Operations: [
|
||||||
|
{
|
||||||
|
op: "Remove",
|
||||||
|
path: "members",
|
||||||
|
value: [
|
||||||
|
{
|
||||||
|
$ref: null,
|
||||||
|
value: users[1].id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
op: "Add",
|
||||||
|
path: "members",
|
||||||
|
value: [
|
||||||
|
{
|
||||||
|
$ref: null,
|
||||||
|
value: users[4].id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await patchScimGroup({ id: group.id, body })
|
||||||
|
|
||||||
|
const expectedScimGroup: ScimGroupResponse = {
|
||||||
|
...group,
|
||||||
|
members: expect.arrayContaining([
|
||||||
|
{
|
||||||
|
value: users[3].id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: users[4].id,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
expect(response).toEqual(expectedScimGroup)
|
||||||
|
|
||||||
|
const persistedGroup = await config.api.scimGroupsAPI.find(group.id)
|
||||||
|
expect(persistedGroup).toEqual(expectedScimGroup)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("adding members and updating fields can performed in a single operation", async () => {
|
||||||
|
const newDisplayName = structures.generator.word()
|
||||||
|
|
||||||
|
const body: ScimUpdateRequest = {
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||||
|
Operations: [
|
||||||
|
{
|
||||||
|
op: "Replace",
|
||||||
|
path: "displayName",
|
||||||
|
value: newDisplayName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
op: "Add",
|
||||||
|
path: "members",
|
||||||
|
value: [
|
||||||
|
{
|
||||||
|
$ref: null,
|
||||||
|
value: users[5].id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await patchScimGroup({ id: group.id, body })
|
||||||
|
|
||||||
|
const expectedScimGroup: ScimGroupResponse = {
|
||||||
|
...group,
|
||||||
|
displayName: newDisplayName,
|
||||||
|
members: expect.arrayContaining([
|
||||||
|
{
|
||||||
|
value: users[3].id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: users[4].id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: users[5].id,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
expect(response).toEqual(expectedScimGroup)
|
||||||
|
|
||||||
|
const persistedGroup = await config.api.scimGroupsAPI.find(group.id)
|
||||||
|
expect(persistedGroup).toEqual(expectedScimGroup)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -1,5 +1,5 @@
|
||||||
import Router from "@koa/router"
|
import Router from "@koa/router"
|
||||||
import { api } from "@budibase/pro"
|
import { api as pro } from "@budibase/pro"
|
||||||
import userRoutes from "./global/users"
|
import userRoutes from "./global/users"
|
||||||
import configRoutes from "./global/configs"
|
import configRoutes from "./global/configs"
|
||||||
import workspaceRoutes from "./global/workspaces"
|
import workspaceRoutes from "./global/workspaces"
|
||||||
|
@ -17,9 +17,6 @@ import migrationRoutes from "./system/migrations"
|
||||||
import accountRoutes from "./system/accounts"
|
import accountRoutes from "./system/accounts"
|
||||||
import restoreRoutes from "./system/restore"
|
import restoreRoutes from "./system/restore"
|
||||||
|
|
||||||
let userGroupRoutes = api.groups
|
|
||||||
let auditLogRoutes = api.auditLogs
|
|
||||||
|
|
||||||
export const routes: Router[] = [
|
export const routes: Router[] = [
|
||||||
configRoutes,
|
configRoutes,
|
||||||
userRoutes,
|
userRoutes,
|
||||||
|
@ -33,10 +30,11 @@ export const routes: Router[] = [
|
||||||
statusRoutes,
|
statusRoutes,
|
||||||
selfRoutes,
|
selfRoutes,
|
||||||
licenseRoutes,
|
licenseRoutes,
|
||||||
userGroupRoutes,
|
pro.groups,
|
||||||
auditLogRoutes,
|
pro.auditLogs,
|
||||||
migrationRoutes,
|
migrationRoutes,
|
||||||
accountRoutes,
|
accountRoutes,
|
||||||
restoreRoutes,
|
restoreRoutes,
|
||||||
eventRoutes,
|
eventRoutes,
|
||||||
|
pro.scim,
|
||||||
]
|
]
|
||||||
|
|
|
@ -35,6 +35,8 @@ const logger = require("koa-pino-logger")
|
||||||
const { userAgent } = require("koa-useragent")
|
const { userAgent } = require("koa-useragent")
|
||||||
|
|
||||||
import destroyable from "server-destroy"
|
import destroyable from "server-destroy"
|
||||||
|
import { initPro } from "./initPro"
|
||||||
|
import { handleScimBody } from "./middleware/handleScimBody"
|
||||||
|
|
||||||
// configure events to use the pro audit log write
|
// configure events to use the pro audit log write
|
||||||
// can't integrate directly into backend-core due to cyclic issues
|
// can't integrate directly into backend-core due to cyclic issues
|
||||||
|
@ -54,7 +56,9 @@ const app: Application = new Koa()
|
||||||
app.keys = ["secret", "key"]
|
app.keys = ["secret", "key"]
|
||||||
|
|
||||||
// set up top level koa middleware
|
// set up top level koa middleware
|
||||||
|
app.use(handleScimBody)
|
||||||
app.use(koaBody({ multipart: true }))
|
app.use(koaBody({ multipart: true }))
|
||||||
|
|
||||||
app.use(koaSession(app))
|
app.use(koaSession(app))
|
||||||
app.use(middleware.logging)
|
app.use(middleware.logging)
|
||||||
app.use(logger(logging.pinoSettings()))
|
app.use(logger(logging.pinoSettings()))
|
||||||
|
@ -108,6 +112,7 @@ const shutdown = () => {
|
||||||
|
|
||||||
export default server.listen(parseInt(env.PORT || "4002"), async () => {
|
export default server.listen(parseInt(env.PORT || "4002"), async () => {
|
||||||
console.log(`Worker running on ${JSON.stringify(server.address())}`)
|
console.log(`Worker running on ${JSON.stringify(server.address())}`)
|
||||||
|
await initPro()
|
||||||
await redis.init()
|
await redis.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { sdk as proSdk } from "@budibase/pro"
|
||||||
|
import * as userSdk from "./sdk/users"
|
||||||
|
|
||||||
|
export const initPro = async () => {
|
||||||
|
await proSdk.init({
|
||||||
|
scimUserServiceConfig: {
|
||||||
|
functions: {
|
||||||
|
saveUser: userSdk.save,
|
||||||
|
removeUser: (id: string) => userSdk.destroy(id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Ctx } from "@budibase/types"
|
||||||
|
|
||||||
|
export const handleScimBody = (ctx: Ctx, next: any) => {
|
||||||
|
var type = ctx.req.headers["content-type"] || ""
|
||||||
|
type = type.split(";")[0]
|
||||||
|
|
||||||
|
if (type === "application/scim+json") {
|
||||||
|
ctx.req.headers["content-type"] = "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
return next()
|
||||||
|
}
|
|
@ -15,6 +15,7 @@ import {
|
||||||
utils,
|
utils,
|
||||||
ViewName,
|
ViewName,
|
||||||
env as coreEnv,
|
env as coreEnv,
|
||||||
|
context,
|
||||||
} from "@budibase/backend-core"
|
} from "@budibase/backend-core"
|
||||||
import {
|
import {
|
||||||
AccountMetadata,
|
AccountMetadata,
|
||||||
|
@ -37,8 +38,6 @@ import { EmailTemplatePurpose } from "../../constants"
|
||||||
import * as pro from "@budibase/pro"
|
import * as pro from "@budibase/pro"
|
||||||
import * as accountSdk from "../accounts"
|
import * as accountSdk from "../accounts"
|
||||||
|
|
||||||
const PAGE_LIMIT = 8
|
|
||||||
|
|
||||||
export const allUsers = async () => {
|
export const allUsers = async () => {
|
||||||
const db = tenancy.getGlobalDB()
|
const db = tenancy.getGlobalDB()
|
||||||
const response = await db.allDocs(
|
const response = await db.allDocs(
|
||||||
|
@ -68,43 +67,6 @@ export const getUsersByAppAccess = async (appId?: string) => {
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
export const paginatedUsers = async ({
|
|
||||||
page,
|
|
||||||
email,
|
|
||||||
appId,
|
|
||||||
}: SearchUsersRequest = {}) => {
|
|
||||||
const db = tenancy.getGlobalDB()
|
|
||||||
// get one extra document, to have the next page
|
|
||||||
const opts: any = {
|
|
||||||
include_docs: true,
|
|
||||||
limit: PAGE_LIMIT + 1,
|
|
||||||
}
|
|
||||||
// add a startkey if the page was specified (anchor)
|
|
||||||
if (page) {
|
|
||||||
opts.startkey = page
|
|
||||||
}
|
|
||||||
// property specifies what to use for the page/anchor
|
|
||||||
let userList,
|
|
||||||
property = "_id",
|
|
||||||
getKey
|
|
||||||
if (appId) {
|
|
||||||
userList = await usersCore.searchGlobalUsersByApp(appId, opts)
|
|
||||||
getKey = (doc: any) => usersCore.getGlobalUserByAppPage(appId, doc)
|
|
||||||
} else if (email) {
|
|
||||||
userList = await usersCore.searchGlobalUsersByEmail(email, opts)
|
|
||||||
property = "email"
|
|
||||||
} else {
|
|
||||||
// no search, query allDocs
|
|
||||||
const response = await db.allDocs(dbUtils.getGlobalUserParams(null, opts))
|
|
||||||
userList = response.rows.map((row: any) => row.doc)
|
|
||||||
}
|
|
||||||
return dbUtils.pagination(userList, PAGE_LIMIT, {
|
|
||||||
paginate: true,
|
|
||||||
property,
|
|
||||||
getKey,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserByEmail(email: string) {
|
export async function getUserByEmail(email: string) {
|
||||||
return usersCore.getGlobalUserByEmail(email)
|
return usersCore.getGlobalUserByEmail(email)
|
||||||
}
|
}
|
||||||
|
@ -576,7 +538,7 @@ export const bulkDelete = async (
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
export const destroy = async (id: string, currentUser: any) => {
|
export const destroy = async (id: string) => {
|
||||||
const db = tenancy.getGlobalDB()
|
const db = tenancy.getGlobalDB()
|
||||||
const dbUser = (await db.get(id)) as User
|
const dbUser = (await db.get(id)) as User
|
||||||
const userId = dbUser._id as string
|
const userId = dbUser._id as string
|
||||||
|
@ -586,7 +548,7 @@ export const destroy = async (id: string, currentUser: any) => {
|
||||||
const email = dbUser.email
|
const email = dbUser.email
|
||||||
const account = await accounts.getAccount(email)
|
const account = await accounts.getAccount(email)
|
||||||
if (account) {
|
if (account) {
|
||||||
if (email === currentUser.email) {
|
if (dbUser.userId === context.getIdentity()!._id) {
|
||||||
throw new HTTPError('Please visit "Account" to delete this user', 400)
|
throw new HTTPError('Please visit "Account" to delete this user', 400)
|
||||||
} else {
|
} else {
|
||||||
throw new HTTPError("Account holder cannot be deleted", 400)
|
throw new HTTPError("Account holder cannot be deleted", 400)
|
||||||
|
|
|
@ -20,9 +20,18 @@ import {
|
||||||
auth,
|
auth,
|
||||||
constants,
|
constants,
|
||||||
env as coreEnv,
|
env as coreEnv,
|
||||||
|
db as dbCore,
|
||||||
|
encryption,
|
||||||
|
utils,
|
||||||
} from "@budibase/backend-core"
|
} from "@budibase/backend-core"
|
||||||
import structures, { CSRF_TOKEN } from "./structures"
|
import structures, { CSRF_TOKEN } from "./structures"
|
||||||
import { SaveUserResponse, User, AuthToken } from "@budibase/types"
|
import {
|
||||||
|
SaveUserResponse,
|
||||||
|
User,
|
||||||
|
AuthToken,
|
||||||
|
SCIMConfig,
|
||||||
|
ConfigType,
|
||||||
|
} from "@budibase/types"
|
||||||
import API from "./api"
|
import API from "./api"
|
||||||
|
|
||||||
class TestConfiguration {
|
class TestConfiguration {
|
||||||
|
@ -31,6 +40,7 @@ class TestConfiguration {
|
||||||
api: API
|
api: API
|
||||||
tenantId: string
|
tenantId: string
|
||||||
user?: User
|
user?: User
|
||||||
|
apiKey?: string
|
||||||
userPassword = "test"
|
userPassword = "test"
|
||||||
|
|
||||||
constructor(opts: { openServer: boolean } = { openServer: true }) {
|
constructor(opts: { openServer: boolean } = { openServer: true }) {
|
||||||
|
@ -49,6 +59,12 @@ class TestConfiguration {
|
||||||
this.api = new API(this)
|
this.api = new API(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async useNewTenant() {
|
||||||
|
this.tenantId = structures.tenant.id()
|
||||||
|
|
||||||
|
await this.beforeAll()
|
||||||
|
}
|
||||||
|
|
||||||
getRequest() {
|
getRequest() {
|
||||||
return this.request
|
return this.request
|
||||||
}
|
}
|
||||||
|
@ -201,6 +217,12 @@ class TestConfiguration {
|
||||||
return { [constants.Header.API_KEY]: coreEnv.INTERNAL_API_KEY }
|
return { [constants.Header.API_KEY]: coreEnv.INTERNAL_API_KEY }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bearerAPIHeaders() {
|
||||||
|
return {
|
||||||
|
[constants.Header.AUTHORIZATION]: `Bearer ${this.apiKey}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
adminOnlyResponse = () => {
|
adminOnlyResponse = () => {
|
||||||
return { message: "Admin user only endpoint.", status: 403 }
|
return { message: "Admin user only endpoint.", status: 403 }
|
||||||
}
|
}
|
||||||
|
@ -213,6 +235,20 @@ class TestConfiguration {
|
||||||
})
|
})
|
||||||
await context.doInTenant(this.tenantId!, async () => {
|
await context.doInTenant(this.tenantId!, async () => {
|
||||||
this.user = await this.createUser(user)
|
this.user = await this.createUser(user)
|
||||||
|
|
||||||
|
const db = context.getGlobalDB()
|
||||||
|
|
||||||
|
const id = dbCore.generateDevInfoID(this.user._id)
|
||||||
|
// TODO: dry
|
||||||
|
this.apiKey = encryption.encrypt(
|
||||||
|
`${this.tenantId}${dbCore.SEPARATOR}${utils.newid()}`
|
||||||
|
)
|
||||||
|
const devInfo = {
|
||||||
|
_id: id,
|
||||||
|
userId: this.user._id,
|
||||||
|
apiKey: this.apiKey,
|
||||||
|
}
|
||||||
|
await db.put(devInfo)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -305,6 +341,19 @@ class TestConfiguration {
|
||||||
controllers.config.save
|
controllers.config.save
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CONFIGS - SCIM
|
||||||
|
|
||||||
|
async setSCIMConfig(enabled: boolean) {
|
||||||
|
await this.deleteConfig(Config.SCIM)
|
||||||
|
const config: SCIMConfig = {
|
||||||
|
type: ConfigType.SCIM,
|
||||||
|
config: { enabled },
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._req(config, null, controllers.config.save)
|
||||||
|
return config
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TestConfiguration
|
export default TestConfiguration
|
||||||
|
|
|
@ -15,6 +15,9 @@ import { RolesAPI } from "./roles"
|
||||||
import { TemplatesAPI } from "./templates"
|
import { TemplatesAPI } from "./templates"
|
||||||
import { LicenseAPI } from "./license"
|
import { LicenseAPI } from "./license"
|
||||||
import { AuditLogAPI } from "./auditLogs"
|
import { AuditLogAPI } from "./auditLogs"
|
||||||
|
import { ScimUsersAPI } from "./scim/users"
|
||||||
|
import { ScimGroupsAPI } from "./scim/groups"
|
||||||
|
|
||||||
export default class API {
|
export default class API {
|
||||||
accounts: AccountAPI
|
accounts: AccountAPI
|
||||||
auth: AuthAPI
|
auth: AuthAPI
|
||||||
|
@ -32,6 +35,8 @@ export default class API {
|
||||||
templates: TemplatesAPI
|
templates: TemplatesAPI
|
||||||
license: LicenseAPI
|
license: LicenseAPI
|
||||||
auditLogs: AuditLogAPI
|
auditLogs: AuditLogAPI
|
||||||
|
scimUsersAPI: ScimUsersAPI
|
||||||
|
scimGroupsAPI: ScimGroupsAPI
|
||||||
|
|
||||||
constructor(config: TestConfiguration) {
|
constructor(config: TestConfiguration) {
|
||||||
this.accounts = new AccountAPI(config)
|
this.accounts = new AccountAPI(config)
|
||||||
|
@ -50,5 +55,7 @@ export default class API {
|
||||||
this.templates = new TemplatesAPI(config)
|
this.templates = new TemplatesAPI(config)
|
||||||
this.license = new LicenseAPI(config)
|
this.license = new LicenseAPI(config)
|
||||||
this.auditLogs = new AuditLogAPI(config)
|
this.auditLogs = new AuditLogAPI(config)
|
||||||
|
this.scimUsersAPI = new ScimUsersAPI(config)
|
||||||
|
this.scimGroupsAPI = new ScimGroupsAPI(config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,94 @@
|
||||||
|
import {
|
||||||
|
ScimCreateGroupRequest,
|
||||||
|
ScimGroupListResponse,
|
||||||
|
ScimGroupResponse,
|
||||||
|
ScimUpdateRequest,
|
||||||
|
} from "@budibase/types"
|
||||||
|
import TestConfiguration from "../../TestConfiguration"
|
||||||
|
import { RequestSettings, ScimTestAPI } from "./shared"
|
||||||
|
|
||||||
|
export class ScimGroupsAPI extends ScimTestAPI {
|
||||||
|
constructor(config: TestConfiguration) {
|
||||||
|
super(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
get = async (
|
||||||
|
requestSettings?: Partial<RequestSettings> & {
|
||||||
|
params?: {
|
||||||
|
startIndex?: number
|
||||||
|
pageSize?: number
|
||||||
|
filter?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
let url = `/api/global/scim/v2/groups?`
|
||||||
|
const params = requestSettings?.params
|
||||||
|
if (params?.pageSize) {
|
||||||
|
url += `count=${params.pageSize}&`
|
||||||
|
}
|
||||||
|
if (params?.startIndex) {
|
||||||
|
url += `startIndex=${params.startIndex}&`
|
||||||
|
}
|
||||||
|
if (params?.filter) {
|
||||||
|
url += `filter=${params.filter}&`
|
||||||
|
}
|
||||||
|
const res = await this.call(url, "get", requestSettings)
|
||||||
|
return res.body as ScimGroupListResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
post = async (
|
||||||
|
{
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
body: ScimCreateGroupRequest
|
||||||
|
},
|
||||||
|
requestSettings?: Partial<RequestSettings>
|
||||||
|
) => {
|
||||||
|
const res = await this.call(
|
||||||
|
`/api/global/scim/v2/groups`,
|
||||||
|
"post",
|
||||||
|
requestSettings,
|
||||||
|
body
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.body as ScimGroupResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
find = async (id: string, requestSettings?: Partial<RequestSettings>) => {
|
||||||
|
const res = await this.call(
|
||||||
|
`/api/global/scim/v2/groups/${id}`,
|
||||||
|
"get",
|
||||||
|
requestSettings
|
||||||
|
)
|
||||||
|
return res.body as ScimGroupResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
delete = async (id: string, requestSettings?: Partial<RequestSettings>) => {
|
||||||
|
const res = await this.call(
|
||||||
|
`/api/global/scim/v2/groups/${id}`,
|
||||||
|
"delete",
|
||||||
|
requestSettings
|
||||||
|
)
|
||||||
|
return res.body as ScimGroupResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
patch = async (
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
body: ScimUpdateRequest
|
||||||
|
},
|
||||||
|
requestSettings?: Partial<RequestSettings>
|
||||||
|
) => {
|
||||||
|
const res = await this.call(
|
||||||
|
`/api/global/scim/v2/groups/${id}`,
|
||||||
|
"patch",
|
||||||
|
requestSettings,
|
||||||
|
body
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.body as ScimGroupResponse
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
import TestConfiguration from "../../TestConfiguration"
|
||||||
|
import { TestAPI } from "../base"
|
||||||
|
|
||||||
|
const defaultConfig = {
|
||||||
|
expect: 200,
|
||||||
|
setHeaders: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestSettings = typeof defaultConfig
|
||||||
|
|
||||||
|
export abstract class ScimTestAPI extends TestAPI {
|
||||||
|
constructor(config: TestConfiguration) {
|
||||||
|
super(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
call = (
|
||||||
|
url: string,
|
||||||
|
method: "get" | "post" | "patch" | "delete",
|
||||||
|
requestSettings?: Partial<RequestSettings>,
|
||||||
|
body?: object
|
||||||
|
) => {
|
||||||
|
const { expect, setHeaders } = { ...defaultConfig, ...requestSettings }
|
||||||
|
let request = this.request[method](url).expect(expect)
|
||||||
|
|
||||||
|
request = request.set(
|
||||||
|
"content-type",
|
||||||
|
"application/scim+json; charset=utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (method !== "delete") {
|
||||||
|
request = request.expect("Content-Type", /json/)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
request = request.send(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setHeaders) {
|
||||||
|
request = request.set(this.config.bearerAPIHeaders())
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,94 @@
|
||||||
|
import {
|
||||||
|
ScimUserListResponse,
|
||||||
|
ScimCreateUserRequest,
|
||||||
|
ScimUserResponse,
|
||||||
|
ScimUpdateRequest,
|
||||||
|
} from "@budibase/types"
|
||||||
|
import TestConfiguration from "../../TestConfiguration"
|
||||||
|
import { RequestSettings, ScimTestAPI } from "./shared"
|
||||||
|
|
||||||
|
export class ScimUsersAPI extends ScimTestAPI {
|
||||||
|
constructor(config: TestConfiguration) {
|
||||||
|
super(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
get = async (
|
||||||
|
requestSettings?: Partial<RequestSettings> & {
|
||||||
|
params?: {
|
||||||
|
startIndex?: number
|
||||||
|
pageSize?: number
|
||||||
|
filter?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
let url = `/api/global/scim/v2/users?`
|
||||||
|
const params = requestSettings?.params
|
||||||
|
if (params?.pageSize) {
|
||||||
|
url += `count=${params.pageSize}&`
|
||||||
|
}
|
||||||
|
if (params?.startIndex) {
|
||||||
|
url += `startIndex=${params.startIndex}&`
|
||||||
|
}
|
||||||
|
if (params?.filter) {
|
||||||
|
url += `filter=${params.filter}&`
|
||||||
|
}
|
||||||
|
const res = await this.call(url, "get", requestSettings)
|
||||||
|
return res.body as ScimUserListResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
find = async (id: string, requestSettings?: Partial<RequestSettings>) => {
|
||||||
|
const res = await this.call(
|
||||||
|
`/api/global/scim/v2/users/${id}`,
|
||||||
|
"get",
|
||||||
|
requestSettings
|
||||||
|
)
|
||||||
|
return res.body as ScimUserResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
post = async (
|
||||||
|
{
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
body: ScimCreateUserRequest
|
||||||
|
},
|
||||||
|
requestSettings?: Partial<RequestSettings>
|
||||||
|
) => {
|
||||||
|
const res = await this.call(
|
||||||
|
`/api/global/scim/v2/users`,
|
||||||
|
"post",
|
||||||
|
requestSettings,
|
||||||
|
body
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.body as ScimUserResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
patch = async (
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
body: ScimUpdateRequest
|
||||||
|
},
|
||||||
|
requestSettings?: Partial<RequestSettings>
|
||||||
|
) => {
|
||||||
|
const res = await this.call(
|
||||||
|
`/api/global/scim/v2/users/${id}`,
|
||||||
|
"patch",
|
||||||
|
requestSettings,
|
||||||
|
body
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.body as ScimUserResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
delete = async (id: string, requestSettings?: Partial<RequestSettings>) => {
|
||||||
|
const res = await this.call(
|
||||||
|
`/api/global/scim/v2/users/${id}`,
|
||||||
|
"delete",
|
||||||
|
requestSettings
|
||||||
|
)
|
||||||
|
return res.body as ScimUserResponse
|
||||||
|
}
|
||||||
|
}
|
|
@ -1621,6 +1621,11 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/koa" "*"
|
"@types/koa" "*"
|
||||||
|
|
||||||
|
"@types/lodash@^4.14.191":
|
||||||
|
version "4.14.191"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.191.tgz#09511e7f7cba275acd8b419ddac8da9a6a79e2fa"
|
||||||
|
integrity sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==
|
||||||
|
|
||||||
"@types/mime@^1":
|
"@types/mime@^1":
|
||||||
version "1.3.2"
|
version "1.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
|
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
|
||||||
|
|
Loading…
Reference in New Issue