Merge pull request #10022 from Budibase/feature/scim
Feature - SCIM endpoints
This commit is contained in:
commit
e1669c8260
|
@ -5,6 +5,8 @@ import {
|
|||
GoogleInnerConfig,
|
||||
OIDCConfig,
|
||||
OIDCInnerConfig,
|
||||
SCIMConfig,
|
||||
SCIMInnerConfig,
|
||||
SettingsConfig,
|
||||
SettingsInnerConfig,
|
||||
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",
|
||||
CSRF_TOKEN = "x-csrf-token",
|
||||
CORRELATION_ID = "x-budibase-correlation-id",
|
||||
AUTHORIZATION = "authorization",
|
||||
}
|
||||
|
||||
export enum GlobalRole {
|
||||
|
@ -38,6 +39,7 @@ export enum Config {
|
|||
GOOGLE = "google",
|
||||
OIDC = "oidc",
|
||||
OIDC_LOGOS = "logos_oidc",
|
||||
SCIM = "scim",
|
||||
}
|
||||
|
||||
export const MIN_VALID_DATE = new Date(-2147483647000)
|
||||
|
|
|
@ -214,6 +214,13 @@ export function doInEnvironmentContext(
|
|||
return newContext(updates, task)
|
||||
}
|
||||
|
||||
export function doInScimContext(task: any) {
|
||||
const updates: ContextMap = {
|
||||
isScim: true,
|
||||
}
|
||||
return newContext(updates, task)
|
||||
}
|
||||
|
||||
export function getEnvironmentVariables() {
|
||||
const context = Context.get()
|
||||
if (!context.environmentVariables) {
|
||||
|
@ -270,3 +277,9 @@ export function getDevAppDB(opts?: any): Database {
|
|||
}
|
||||
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"
|
||||
const context = require("../")
|
||||
const { DEFAULT_TENANT_ID } = require("../../constants")
|
||||
import * as context from "../"
|
||||
import { DEFAULT_TENANT_ID } from "../../constants"
|
||||
|
||||
describe("context", () => {
|
||||
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
|
||||
identity?: IdentityContext
|
||||
environmentVariables?: Record<string, string>
|
||||
isScim?: boolean
|
||||
}
|
||||
|
|
|
@ -8,3 +8,4 @@ export { default as Replication } from "./Replication"
|
|||
export * from "../constants/db"
|
||||
export { getGlobalDBName, baseGlobalDBName } from "../context"
|
||||
export * from "./lucene"
|
||||
export * as searchIndexes from "./searchIndexes"
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
import fetch from "node-fetch"
|
||||
import { getCouchInfo } from "./couch"
|
||||
import { SearchFilters, Row } from "@budibase/types"
|
||||
import { createUserIndex } from "./searchIndexes/searchIndexes"
|
||||
|
||||
const QUERY_START_REGEX = /\d[0-9]*:/g
|
||||
|
||||
interface SearchResponse<T> {
|
||||
rows: T[] | any[]
|
||||
bookmark: string
|
||||
bookmark?: string
|
||||
totalRows: number
|
||||
}
|
||||
|
||||
interface PaginatedSearchResponse<T> extends SearchResponse<T> {
|
||||
|
@ -42,23 +44,26 @@ export function removeKeyNumbering(key: any): string {
|
|||
* Optionally takes a base lucene query object.
|
||||
*/
|
||||
export class QueryBuilder<T> {
|
||||
dbName: string
|
||||
index: string
|
||||
query: SearchFilters
|
||||
limit: number
|
||||
sort?: string
|
||||
bookmark?: string
|
||||
sortOrder: string
|
||||
sortType: string
|
||||
includeDocs: boolean
|
||||
version?: string
|
||||
indexBuilder?: () => Promise<any>
|
||||
noEscaping = false
|
||||
#dbName: string
|
||||
#index: string
|
||||
#query: SearchFilters
|
||||
#limit: number
|
||||
#sort?: string
|
||||
#bookmark?: string
|
||||
#sortOrder: string
|
||||
#sortType: string
|
||||
#includeDocs: boolean
|
||||
#version?: string
|
||||
#indexBuilder?: () => Promise<any>
|
||||
#noEscaping = false
|
||||
#skip?: number
|
||||
|
||||
static readonly maxLimit = 200
|
||||
|
||||
constructor(dbName: string, index: string, base?: SearchFilters) {
|
||||
this.dbName = dbName
|
||||
this.index = index
|
||||
this.query = {
|
||||
this.#dbName = dbName
|
||||
this.#index = index
|
||||
this.#query = {
|
||||
allOr: false,
|
||||
string: {},
|
||||
fuzzy: {},
|
||||
|
@ -73,86 +78,96 @@ export class QueryBuilder<T> {
|
|||
containsAny: {},
|
||||
...base,
|
||||
}
|
||||
this.limit = 50
|
||||
this.sortOrder = "ascending"
|
||||
this.sortType = "string"
|
||||
this.includeDocs = true
|
||||
this.#limit = 50
|
||||
this.#sortOrder = "ascending"
|
||||
this.#sortType = "string"
|
||||
this.#includeDocs = true
|
||||
}
|
||||
|
||||
disableEscaping() {
|
||||
this.noEscaping = true
|
||||
this.#noEscaping = true
|
||||
return this
|
||||
}
|
||||
|
||||
setIndexBuilder(builderFn: () => Promise<any>) {
|
||||
this.indexBuilder = builderFn
|
||||
this.#indexBuilder = builderFn
|
||||
return this
|
||||
}
|
||||
|
||||
setVersion(version?: string) {
|
||||
if (version != null) {
|
||||
this.version = version
|
||||
this.#version = version
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
setTable(tableId: string) {
|
||||
this.query.equal!.tableId = tableId
|
||||
this.#query.equal!.tableId = tableId
|
||||
return this
|
||||
}
|
||||
|
||||
setLimit(limit?: number) {
|
||||
if (limit != null) {
|
||||
this.limit = limit
|
||||
this.#limit = limit
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
setSort(sort?: string) {
|
||||
if (sort != null) {
|
||||
this.sort = sort
|
||||
this.#sort = sort
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
setSortOrder(sortOrder?: string) {
|
||||
if (sortOrder != null) {
|
||||
this.sortOrder = sortOrder
|
||||
this.#sortOrder = sortOrder
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
setSortType(sortType?: string) {
|
||||
if (sortType != null) {
|
||||
this.sortType = sortType
|
||||
this.#sortType = sortType
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
setBookmark(bookmark?: string) {
|
||||
if (bookmark != null) {
|
||||
this.bookmark = bookmark
|
||||
this.#bookmark = bookmark
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
setSkip(skip: number | undefined) {
|
||||
this.#skip = skip
|
||||
return this
|
||||
}
|
||||
|
||||
excludeDocs() {
|
||||
this.includeDocs = false
|
||||
this.#includeDocs = false
|
||||
return this
|
||||
}
|
||||
|
||||
includeDocs() {
|
||||
this.#includeDocs = true
|
||||
return this
|
||||
}
|
||||
|
||||
addString(key: string, partial: string) {
|
||||
this.query.string![key] = partial
|
||||
this.#query.string![key] = partial
|
||||
return this
|
||||
}
|
||||
|
||||
addFuzzy(key: string, fuzzy: string) {
|
||||
this.query.fuzzy![key] = fuzzy
|
||||
this.#query.fuzzy![key] = fuzzy
|
||||
return this
|
||||
}
|
||||
|
||||
addRange(key: string, low: string | number, high: string | number) {
|
||||
this.query.range![key] = {
|
||||
this.#query.range![key] = {
|
||||
low,
|
||||
high,
|
||||
}
|
||||
|
@ -160,51 +175,51 @@ export class QueryBuilder<T> {
|
|||
}
|
||||
|
||||
addEqual(key: string, value: any) {
|
||||
this.query.equal![key] = value
|
||||
this.#query.equal![key] = value
|
||||
return this
|
||||
}
|
||||
|
||||
addNotEqual(key: string, value: any) {
|
||||
this.query.notEqual![key] = value
|
||||
this.#query.notEqual![key] = value
|
||||
return this
|
||||
}
|
||||
|
||||
addEmpty(key: string, value: any) {
|
||||
this.query.empty![key] = value
|
||||
this.#query.empty![key] = value
|
||||
return this
|
||||
}
|
||||
|
||||
addNotEmpty(key: string, value: any) {
|
||||
this.query.notEmpty![key] = value
|
||||
this.#query.notEmpty![key] = value
|
||||
return this
|
||||
}
|
||||
|
||||
addOneOf(key: string, value: any) {
|
||||
this.query.oneOf![key] = value
|
||||
this.#query.oneOf![key] = value
|
||||
return this
|
||||
}
|
||||
|
||||
addContains(key: string, value: any) {
|
||||
this.query.contains![key] = value
|
||||
this.#query.contains![key] = value
|
||||
return this
|
||||
}
|
||||
|
||||
addNotContains(key: string, value: any) {
|
||||
this.query.notContains![key] = value
|
||||
this.#query.notContains![key] = value
|
||||
return this
|
||||
}
|
||||
|
||||
addContainsAny(key: string, value: any) {
|
||||
this.query.containsAny![key] = value
|
||||
this.#query.containsAny![key] = value
|
||||
return this
|
||||
}
|
||||
|
||||
setAllOr() {
|
||||
this.query.allOr = true
|
||||
this.#query.allOr = true
|
||||
}
|
||||
|
||||
handleSpaces(input: string) {
|
||||
if (this.noEscaping) {
|
||||
if (this.#noEscaping) {
|
||||
return input
|
||||
} else {
|
||||
return input.replace(/ /g, "_")
|
||||
|
@ -219,7 +234,7 @@ export class QueryBuilder<T> {
|
|||
* @returns {string|*}
|
||||
*/
|
||||
preprocess(value: any, { escape, lowercase, wrap, type }: any = {}) {
|
||||
const hasVersion = !!this.version
|
||||
const hasVersion = !!this.#version
|
||||
// Determine if type needs wrapped
|
||||
const originalType = typeof value
|
||||
// Convert to lowercase
|
||||
|
@ -227,7 +242,7 @@ export class QueryBuilder<T> {
|
|||
value = value.toLowerCase ? value.toLowerCase() : value
|
||||
}
|
||||
// Escape characters
|
||||
if (!this.noEscaping && escape && originalType === "string") {
|
||||
if (!this.#noEscaping && escape && originalType === "string") {
|
||||
value = `${value}`.replace(/[ #+\-&|!(){}\]^"~*?:\\]/g, "\\$&")
|
||||
}
|
||||
|
||||
|
@ -242,7 +257,7 @@ export class QueryBuilder<T> {
|
|||
|
||||
isMultiCondition() {
|
||||
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
|
||||
if (typeof filters === "object") {
|
||||
count += Object.keys(filters).length
|
||||
|
@ -272,13 +287,13 @@ export class QueryBuilder<T> {
|
|||
|
||||
buildSearchQuery() {
|
||||
const builder = this
|
||||
let allOr = this.query && this.query.allOr
|
||||
let allOr = this.#query && this.#query.allOr
|
||||
let query = allOr ? "" : "*:*"
|
||||
const allPreProcessingOpts = { escape: true, lowercase: true, wrap: true }
|
||||
let tableId
|
||||
if (this.query.equal!.tableId) {
|
||||
tableId = this.query.equal!.tableId
|
||||
delete this.query.equal!.tableId
|
||||
if (this.#query.equal!.tableId) {
|
||||
tableId = this.#query.equal!.tableId
|
||||
delete this.#query.equal!.tableId
|
||||
}
|
||||
|
||||
const equal = (key: string, value: any) => {
|
||||
|
@ -363,8 +378,8 @@ export class QueryBuilder<T> {
|
|||
}
|
||||
|
||||
// Construct the actual lucene search query string from JSON structure
|
||||
if (this.query.string) {
|
||||
build(this.query.string, (key: string, value: any) => {
|
||||
if (this.#query.string) {
|
||||
build(this.#query.string, (key: string, value: any) => {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
@ -376,8 +391,8 @@ export class QueryBuilder<T> {
|
|||
return `${key}:${value}*`
|
||||
})
|
||||
}
|
||||
if (this.query.range) {
|
||||
build(this.query.range, (key: string, value: any) => {
|
||||
if (this.#query.range) {
|
||||
build(this.#query.range, (key: string, value: any) => {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
@ -392,8 +407,8 @@ export class QueryBuilder<T> {
|
|||
return `${key}:[${low} TO ${high}]`
|
||||
})
|
||||
}
|
||||
if (this.query.fuzzy) {
|
||||
build(this.query.fuzzy, (key: string, value: any) => {
|
||||
if (this.#query.fuzzy) {
|
||||
build(this.#query.fuzzy, (key: string, value: any) => {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
@ -405,34 +420,34 @@ export class QueryBuilder<T> {
|
|||
return `${key}:${value}~`
|
||||
})
|
||||
}
|
||||
if (this.query.equal) {
|
||||
build(this.query.equal, equal)
|
||||
if (this.#query.equal) {
|
||||
build(this.#query.equal, equal)
|
||||
}
|
||||
if (this.query.notEqual) {
|
||||
build(this.query.notEqual, (key: string, value: any) => {
|
||||
if (this.#query.notEqual) {
|
||||
build(this.#query.notEqual, (key: string, value: any) => {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
return `!${key}:${builder.preprocess(value, allPreProcessingOpts)}`
|
||||
})
|
||||
}
|
||||
if (this.query.empty) {
|
||||
build(this.query.empty, (key: string) => `!${key}:["" TO *]`)
|
||||
if (this.#query.empty) {
|
||||
build(this.#query.empty, (key: string) => `!${key}:["" TO *]`)
|
||||
}
|
||||
if (this.query.notEmpty) {
|
||||
build(this.query.notEmpty, (key: string) => `${key}:["" TO *]`)
|
||||
if (this.#query.notEmpty) {
|
||||
build(this.#query.notEmpty, (key: string) => `${key}:["" TO *]`)
|
||||
}
|
||||
if (this.query.oneOf) {
|
||||
build(this.query.oneOf, oneOf)
|
||||
if (this.#query.oneOf) {
|
||||
build(this.#query.oneOf, oneOf)
|
||||
}
|
||||
if (this.query.contains) {
|
||||
build(this.query.contains, contains)
|
||||
if (this.#query.contains) {
|
||||
build(this.#query.contains, contains)
|
||||
}
|
||||
if (this.query.notContains) {
|
||||
build(this.compressFilters(this.query.notContains), notContains)
|
||||
if (this.#query.notContains) {
|
||||
build(this.compressFilters(this.#query.notContains), notContains)
|
||||
}
|
||||
if (this.query.containsAny) {
|
||||
build(this.query.containsAny, containsAny)
|
||||
if (this.#query.containsAny) {
|
||||
build(this.#query.containsAny, containsAny)
|
||||
}
|
||||
// make sure table ID is always added as an AND
|
||||
if (tableId) {
|
||||
|
@ -446,29 +461,65 @@ export class QueryBuilder<T> {
|
|||
buildSearchBody() {
|
||||
let body: any = {
|
||||
q: this.buildSearchQuery(),
|
||||
limit: Math.min(this.limit, 200),
|
||||
include_docs: this.includeDocs,
|
||||
limit: Math.min(this.#limit, QueryBuilder.maxLimit),
|
||||
include_docs: this.#includeDocs,
|
||||
}
|
||||
if (this.bookmark) {
|
||||
body.bookmark = this.bookmark
|
||||
if (this.#bookmark) {
|
||||
body.bookmark = this.#bookmark
|
||||
}
|
||||
if (this.sort) {
|
||||
const order = this.sortOrder === "descending" ? "-" : ""
|
||||
const type = `<${this.sortType}>`
|
||||
body.sort = `${order}${this.handleSpaces(this.sort)}${type}`
|
||||
if (this.#sort) {
|
||||
const order = this.#sortOrder === "descending" ? "-" : ""
|
||||
const type = `<${this.#sortType}>`
|
||||
body.sort = `${order}${this.handleSpaces(this.#sort)}${type}`
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
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 fullPath = `${url}/${this.dbName}/_design/database/_search/${this.index}`
|
||||
const fullPath = `${url}/${this.#dbName}/_design/database/_search/${
|
||||
this.#index
|
||||
}`
|
||||
const body = this.buildSearchBody()
|
||||
try {
|
||||
return await runQuery<T>(fullPath, body, cookie)
|
||||
} catch (err: any) {
|
||||
if (err.status === 404 && this.indexBuilder) {
|
||||
await this.indexBuilder()
|
||||
if (err.status === 404 && this.#indexBuilder) {
|
||||
await this.#indexBuilder()
|
||||
return await runQuery<T>(fullPath, body, cookie)
|
||||
} else {
|
||||
throw err
|
||||
|
@ -502,8 +553,9 @@ async function runQuery<T>(
|
|||
}
|
||||
const json = await response.json()
|
||||
|
||||
let output: any = {
|
||||
let output: SearchResponse<T> = {
|
||||
rows: [],
|
||||
totalRows: 0,
|
||||
}
|
||||
if (json.rows != null && json.rows.length > 0) {
|
||||
output.rows = json.rows.map((row: any) => row.doc)
|
||||
|
@ -511,6 +563,9 @@ async function runQuery<T>(
|
|||
if (json.bookmark) {
|
||||
output.bookmark = json.bookmark
|
||||
}
|
||||
if (json.total_rows) {
|
||||
output.totalRows = json.total_rows
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
|
@ -543,8 +598,8 @@ async function recursiveSearch<T>(
|
|||
if (rows.length >= params.limit) {
|
||||
return rows
|
||||
}
|
||||
let pageSize = 200
|
||||
if (rows.length > params.limit - 200) {
|
||||
let pageSize = QueryBuilder.maxLimit
|
||||
if (rows.length > params.limit - QueryBuilder.maxLimit) {
|
||||
pageSize = params.limit - rows.length
|
||||
}
|
||||
const page = await new QueryBuilder<T>(dbName, index, query)
|
||||
|
@ -559,7 +614,7 @@ async function recursiveSearch<T>(
|
|||
if (!page.rows.length) {
|
||||
return rows
|
||||
}
|
||||
if (page.rows.length < 200) {
|
||||
if (page.rows.length < QueryBuilder.maxLimit) {
|
||||
return [...rows, ...page.rows]
|
||||
}
|
||||
const newParams = {
|
||||
|
@ -597,7 +652,7 @@ export async function paginatedSearch<T>(
|
|||
if (limit == null || isNaN(limit) || limit < 0) {
|
||||
limit = 50
|
||||
}
|
||||
limit = Math.min(limit, 200)
|
||||
limit = Math.min(limit, QueryBuilder.maxLimit)
|
||||
const search = new QueryBuilder<T>(dbName, index, query)
|
||||
if (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()
|
||||
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", () => {
|
||||
|
|
|
@ -434,8 +434,8 @@ export const getPluginParams = (pluginId?: string | null, otherProps = {}) => {
|
|||
return getDocParams(DocumentType.PLUGIN, pluginId, otherProps)
|
||||
}
|
||||
|
||||
export function pagination(
|
||||
data: any[],
|
||||
export function pagination<T>(
|
||||
data: T[],
|
||||
pageSize: number,
|
||||
{
|
||||
paginate,
|
||||
|
@ -444,7 +444,7 @@ export function pagination(
|
|||
}: {
|
||||
paginate: boolean
|
||||
property: string
|
||||
getKey?: (doc: any) => string | undefined
|
||||
getKey?: (doc: T) => string | undefined
|
||||
} = {
|
||||
paginate: true,
|
||||
property: "_id",
|
||||
|
|
|
@ -9,12 +9,13 @@ import {
|
|||
GroupUsersDeletedEvent,
|
||||
GroupAddedOnboardingEvent,
|
||||
GroupPermissionsEditedEvent,
|
||||
UserGroupRoles,
|
||||
} from "@budibase/types"
|
||||
import { isScim } from "../../context"
|
||||
|
||||
async function created(group: UserGroup, timestamp?: number) {
|
||||
const properties: GroupCreatedEvent = {
|
||||
groupId: group._id as string,
|
||||
viaScim: isScim(),
|
||||
audited: {
|
||||
name: group.name,
|
||||
},
|
||||
|
@ -25,6 +26,7 @@ async function created(group: UserGroup, timestamp?: number) {
|
|||
async function updated(group: UserGroup) {
|
||||
const properties: GroupUpdatedEvent = {
|
||||
groupId: group._id as string,
|
||||
viaScim: isScim(),
|
||||
audited: {
|
||||
name: group.name,
|
||||
},
|
||||
|
@ -35,6 +37,7 @@ async function updated(group: UserGroup) {
|
|||
async function deleted(group: UserGroup) {
|
||||
const properties: GroupDeletedEvent = {
|
||||
groupId: group._id as string,
|
||||
viaScim: isScim(),
|
||||
audited: {
|
||||
name: group.name,
|
||||
},
|
||||
|
@ -46,6 +49,7 @@ async function usersAdded(count: number, group: UserGroup) {
|
|||
const properties: GroupUsersAddedEvent = {
|
||||
count,
|
||||
groupId: group._id as string,
|
||||
viaScim: isScim(),
|
||||
audited: {
|
||||
name: group.name,
|
||||
},
|
||||
|
@ -57,6 +61,7 @@ async function usersDeleted(count: number, group: UserGroup) {
|
|||
const properties: GroupUsersDeletedEvent = {
|
||||
count,
|
||||
groupId: group._id as string,
|
||||
viaScim: isScim(),
|
||||
audited: {
|
||||
name: group.name,
|
||||
},
|
||||
|
|
|
@ -15,10 +15,12 @@ import {
|
|||
UserUpdatedEvent,
|
||||
UserOnboardingEvent,
|
||||
} from "@budibase/types"
|
||||
import { isScim } from "../../context"
|
||||
|
||||
async function created(user: User, timestamp?: number) {
|
||||
const properties: UserCreatedEvent = {
|
||||
userId: user._id as string,
|
||||
viaScim: isScim(),
|
||||
audited: {
|
||||
email: user.email,
|
||||
},
|
||||
|
@ -29,6 +31,7 @@ async function created(user: User, timestamp?: number) {
|
|||
async function updated(user: User) {
|
||||
const properties: UserUpdatedEvent = {
|
||||
userId: user._id as string,
|
||||
viaScim: isScim(),
|
||||
audited: {
|
||||
email: user.email,
|
||||
},
|
||||
|
@ -39,6 +42,7 @@ async function updated(user: User) {
|
|||
async function deleted(user: User) {
|
||||
const properties: UserDeletedEvent = {
|
||||
userId: user._id as string,
|
||||
viaScim: isScim(),
|
||||
audited: {
|
||||
email: user.email,
|
||||
},
|
||||
|
|
|
@ -96,9 +96,15 @@ export default function (
|
|||
}
|
||||
try {
|
||||
// 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 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]
|
||||
let authenticated = false,
|
||||
user = null,
|
||||
|
|
|
@ -8,8 +8,10 @@ import {
|
|||
DocumentType,
|
||||
SEPARATOR,
|
||||
directCouchFind,
|
||||
getGlobalUserParams,
|
||||
pagination,
|
||||
} from "./db"
|
||||
import { BulkDocsResponse, User } from "@budibase/types"
|
||||
import { BulkDocsResponse, SearchUsersRequest, User } from "@budibase/types"
|
||||
import { getGlobalDB } from "./context"
|
||||
import * as context from "./context"
|
||||
|
||||
|
@ -199,3 +201,41 @@ export const searchGlobalUsersByEmail = async (
|
|||
}
|
||||
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)
|
||||
}
|
||||
|
||||
export const useScimIntegration = () => {
|
||||
return useFeature(Feature.SCIM)
|
||||
}
|
||||
|
||||
// QUOTAS
|
||||
|
||||
export const setAutomationLogsQuota = (value: number) => {
|
||||
|
|
|
@ -10,3 +10,4 @@ export * as tenant from "./tenants"
|
|||
export * as users from "./users"
|
||||
export * as userGroups from "./userGroups"
|
||||
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 { API } from "api"
|
||||
import { organisation, admin, licensing } from "stores/portal"
|
||||
import Scim from "./scim.svelte"
|
||||
|
||||
const ConfigTypes = {
|
||||
Google: "google",
|
||||
|
@ -606,12 +607,17 @@
|
|||
</Tags>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<div>
|
||||
<Button disabled={oidcSaveButtonDisabled} cta on:click={() => saveOIDC()}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $licensing.scimEnabled}
|
||||
<Divider />
|
||||
<Scim />
|
||||
{/if}
|
||||
</Layout>
|
||||
|
||||
<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>
|
||||
import { Page } from "@budibase/bbui"
|
||||
import { Page, notifications } from "@budibase/bbui"
|
||||
import { onMount } from "svelte"
|
||||
import { SideNav, SideNavItem, Content } from "components/portal/page"
|
||||
import { isActive, goto } from "@roxi/routify"
|
||||
import { menu } from "stores/portal"
|
||||
import { menu, features } from "stores/portal"
|
||||
|
||||
$: wide = $isActive("./users/index") || $isActive("./groups/index")
|
||||
$: pages = $menu.find(x => x.title === "Users")?.subPages || []
|
||||
$: !pages.length && $goto("../")
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await features.init()
|
||||
} catch (error) {
|
||||
notifications.error(
|
||||
`Error fetching feature configs - ${error?.message || "unknown error"}`
|
||||
)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Page>
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
} from "@budibase/bbui"
|
||||
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
|
||||
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 { roles } from "stores/backend"
|
||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||
|
@ -24,18 +24,23 @@
|
|||
import AppNameTableRenderer from "../users/_components/AppNameTableRenderer.svelte"
|
||||
import RemoveUserTableRenderer from "./_components/RemoveUserTableRenderer.svelte"
|
||||
import AppRoleTableRenderer from "../users/_components/AppRoleTableRenderer.svelte"
|
||||
import ScimBanner from "../_components/SCIMBanner.svelte"
|
||||
|
||||
export let groupId
|
||||
|
||||
const userSchema = {
|
||||
$: userSchema = {
|
||||
email: {
|
||||
width: "1fr",
|
||||
},
|
||||
_id: {
|
||||
displayName: "",
|
||||
width: "auto",
|
||||
borderLeft: true,
|
||||
},
|
||||
...(readonly
|
||||
? {}
|
||||
: {
|
||||
_id: {
|
||||
displayName: "",
|
||||
width: "auto",
|
||||
borderLeft: true,
|
||||
},
|
||||
}),
|
||||
}
|
||||
const appSchema = {
|
||||
name: {
|
||||
|
@ -70,7 +75,9 @@
|
|||
let loaded = false
|
||||
let editModal, deleteModal
|
||||
|
||||
$: readonly = !$auth.isAdmin
|
||||
const scimEnabled = $features.isScimEnabled
|
||||
|
||||
$: readonly = !$auth.isAdmin || scimEnabled
|
||||
$: page = $pageInfo.page
|
||||
$: fetchUsers(page, searchTerm)
|
||||
$: group = $groups.find(x => x._id === groupId)
|
||||
|
@ -182,11 +189,15 @@
|
|||
<Layout noPadding gap="S">
|
||||
<div class="header">
|
||||
<Heading size="S">Users</Heading>
|
||||
<div bind:this={popoverAnchor}>
|
||||
<Button disabled={readonly} on:click={popover.show()} cta
|
||||
>Add user</Button
|
||||
>
|
||||
</div>
|
||||
{#if !scimEnabled}
|
||||
<div bind:this={popoverAnchor}>
|
||||
<Button disabled={readonly} on:click={popover.show()} cta
|
||||
>Add user</Button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<ScimBanner />
|
||||
{/if}
|
||||
<Popover align="right" bind:this={popover} anchor={popoverAnchor}>
|
||||
<UserGroupPicker
|
||||
bind:searchTerm
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
Search,
|
||||
notifications,
|
||||
} 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 CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
|
@ -21,6 +21,7 @@
|
|||
import UsersTableRenderer from "./_components/UsersTableRenderer.svelte"
|
||||
import GroupNameTableRenderer from "./_components/GroupNameTableRenderer.svelte"
|
||||
import { goto } from "@roxi/routify"
|
||||
import ScimBanner from "../_components/SCIMBanner.svelte"
|
||||
|
||||
const DefaultGroup = {
|
||||
name: "",
|
||||
|
@ -106,10 +107,14 @@
|
|||
<div class="controls">
|
||||
<ButtonGroup>
|
||||
{#if $licensing.groupsEnabled}
|
||||
<!--Show the group create button-->
|
||||
<Button disabled={readonly} cta on:click={showCreateGroupModal}>
|
||||
Add group
|
||||
</Button>
|
||||
{#if !$features.isScimEnabled}
|
||||
<!--Show the group create button-->
|
||||
<Button disabled={readonly} cta on:click={showCreateGroupModal}>
|
||||
Add group
|
||||
</Button>
|
||||
{:else}
|
||||
<ScimBanner />
|
||||
{/if}
|
||||
{:else}
|
||||
<Button
|
||||
primary
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
Table,
|
||||
} from "@budibase/bbui"
|
||||
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 ForceResetPasswordModal from "./_components/ForceResetPasswordModal.svelte"
|
||||
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
|
||||
|
@ -31,18 +31,23 @@
|
|||
import GroupNameTableRenderer from "../groups/_components/GroupNameTableRenderer.svelte"
|
||||
import AppNameTableRenderer from "./_components/AppNameTableRenderer.svelte"
|
||||
import AppRoleTableRenderer from "./_components/AppRoleTableRenderer.svelte"
|
||||
import ScimBanner from "../_components/SCIMBanner.svelte"
|
||||
|
||||
export let userId
|
||||
|
||||
const groupSchema = {
|
||||
$: groupSchema = {
|
||||
name: {
|
||||
width: "1fr",
|
||||
},
|
||||
_id: {
|
||||
displayName: "",
|
||||
width: "auto",
|
||||
borderLeft: true,
|
||||
},
|
||||
...(readonly
|
||||
? {}
|
||||
: {
|
||||
_id: {
|
||||
displayName: "",
|
||||
width: "auto",
|
||||
borderLeft: true,
|
||||
},
|
||||
}),
|
||||
}
|
||||
const appSchema = {
|
||||
name: {
|
||||
|
@ -81,9 +86,10 @@
|
|||
let user
|
||||
let loaded = false
|
||||
|
||||
const scimEnabled = $features.isScimEnabled
|
||||
|
||||
$: isSSO = !!user?.provider
|
||||
$: readonly = !$auth.isAdmin
|
||||
$: fullName = user?.firstName ? user?.firstName + " " + user?.lastName : ""
|
||||
$: readonly = !$auth.isAdmin || scimEnabled
|
||||
$: privileged = user?.admin?.global || user?.builder?.global
|
||||
$: nameLabel = getNameLabel(user)
|
||||
$: initials = getInitials(nameLabel)
|
||||
|
@ -260,7 +266,12 @@
|
|||
{/if}
|
||||
</div>
|
||||
<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="field">
|
||||
<Label size="L">Email</Label>
|
||||
|
@ -284,10 +295,11 @@
|
|||
</div>
|
||||
<!-- don't let a user remove the privileges that let them be here -->
|
||||
{#if userId !== $auth.user._id}
|
||||
<!-- Disabled if it's not admin, enabled for SCIM integration -->
|
||||
<div class="field">
|
||||
<Label size="L">Role</Label>
|
||||
<Select
|
||||
disabled={readonly}
|
||||
disabled={!$auth.isAdmin}
|
||||
value={globalRole}
|
||||
options={Constants.BudibaseRoleOptions}
|
||||
on:change={updateUserRole}
|
||||
|
@ -404,4 +416,9 @@
|
|||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.details-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -13,7 +13,14 @@
|
|||
Divider,
|
||||
} from "@budibase/bbui"
|
||||
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 DeleteRowsButton from "components/backend/DataTable/buttons/DeleteRowsButton.svelte"
|
||||
import GroupsTableRenderer from "./_components/GroupsTableRenderer.svelte"
|
||||
|
@ -28,6 +35,7 @@
|
|||
import { Constants, Utils, fetchData } from "@budibase/frontend-core"
|
||||
import { API } from "api"
|
||||
import { OnboardingType } from "../../../../../constants"
|
||||
import ScimBanner from "../_components/SCIMBanner.svelte"
|
||||
|
||||
const fetch = fetchData({
|
||||
API,
|
||||
|
@ -53,7 +61,7 @@
|
|||
]
|
||||
let userData = []
|
||||
|
||||
$: readonly = !$auth.isAdmin
|
||||
$: readonly = !$auth.isAdmin || $features.isScimEnabled
|
||||
$: debouncedUpdateFetch(searchEmail)
|
||||
$: schema = {
|
||||
email: {
|
||||
|
@ -230,14 +238,18 @@
|
|||
</Layout>
|
||||
<Divider />
|
||||
<div class="controls">
|
||||
<ButtonGroup>
|
||||
<Button disabled={readonly} on:click={createUserModal.show} cta>
|
||||
Add users
|
||||
</Button>
|
||||
<Button disabled={readonly} on:click={importUsersModal.show} secondary>
|
||||
Import
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
{#if !readonly}
|
||||
<ButtonGroup>
|
||||
<Button disabled={readonly} on:click={createUserModal.show} cta>
|
||||
Add users
|
||||
</Button>
|
||||
<Button disabled={readonly} on:click={importUsersModal.show} secondary>
|
||||
Import
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
{:else}
|
||||
<ScimBanner />
|
||||
{/if}
|
||||
<div class="controls-right">
|
||||
<Search bind:value={searchEmail} placeholder="Search" />
|
||||
{#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 { menu } from "./menu"
|
||||
export { auditLogs } from "./auditLogs"
|
||||
export { features } from "./features"
|
||||
|
|
|
@ -18,6 +18,7 @@ export const createLicensingStore = () => {
|
|||
groupsEnabled: false,
|
||||
backupsEnabled: false,
|
||||
brandingEnabled: false,
|
||||
scimEnabled: false,
|
||||
// the currently used quotas from the db
|
||||
quotaUsage: undefined,
|
||||
// derived quota metrics for percentages used
|
||||
|
@ -66,6 +67,7 @@ export const createLicensingStore = () => {
|
|||
const backupsEnabled = license.features.includes(
|
||||
Constants.Features.BACKUPS
|
||||
)
|
||||
const scimEnabled = license.features.includes(Constants.Features.SCIM)
|
||||
const environmentVariablesEnabled = license.features.includes(
|
||||
Constants.Features.ENVIRONMENT_VARIABLES
|
||||
)
|
||||
|
@ -88,6 +90,7 @@ export const createLicensingStore = () => {
|
|||
groupsEnabled,
|
||||
backupsEnabled,
|
||||
brandingEnabled,
|
||||
scimEnabled,
|
||||
environmentVariablesEnabled,
|
||||
auditLogsEnabled,
|
||||
enforceableSSO,
|
||||
|
|
|
@ -69,6 +69,7 @@ export const Features = {
|
|||
AUDIT_LOGS: "auditLogs",
|
||||
ENFORCEABLE_SSO: "enforceableSSO",
|
||||
BRANDING: "branding",
|
||||
SCIM: "scim",
|
||||
}
|
||||
|
||||
// Role IDs
|
||||
|
|
|
@ -29,5 +29,8 @@
|
|||
"koa-body": "4.2.0",
|
||||
"rimraf": "3.0.2",
|
||||
"typescript": "4.7.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"scim-patch": "^0.7.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,3 +2,4 @@ export * from "./environmentVariables"
|
|||
export * from "./auditLogs"
|
||||
export * from "./events"
|
||||
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 {
|
||||
bookmark: string
|
||||
bookmark: string | undefined
|
||||
hasNextPage: boolean
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { Document } from "../document"
|
||||
|
||||
export interface Config extends Document {
|
||||
export interface Config<T = any> extends Document {
|
||||
type: ConfigType
|
||||
config: any
|
||||
config: T
|
||||
}
|
||||
|
||||
export interface SMTPInnerConfig {
|
||||
|
@ -18,9 +18,7 @@ export interface SMTPInnerConfig {
|
|||
connectionTimeout?: any
|
||||
}
|
||||
|
||||
export interface SMTPConfig extends Config {
|
||||
config: SMTPInnerConfig
|
||||
}
|
||||
export interface SMTPConfig extends Config<SMTPInnerConfig> {}
|
||||
|
||||
/**
|
||||
* Accessible only via pro.
|
||||
|
@ -50,9 +48,7 @@ export interface SettingsInnerConfig {
|
|||
isSSOEnforced?: boolean
|
||||
}
|
||||
|
||||
export interface SettingsConfig extends Config {
|
||||
config: SettingsInnerConfig
|
||||
}
|
||||
export interface SettingsConfig extends Config<SettingsInnerConfig> {}
|
||||
|
||||
export type SSOConfigType = ConfigType.GOOGLE | ConfigType.OIDC
|
||||
export type SSOConfig = GoogleInnerConfig | OIDCInnerConfig
|
||||
|
@ -67,9 +63,7 @@ export interface GoogleInnerConfig {
|
|||
callbackURL?: string
|
||||
}
|
||||
|
||||
export interface GoogleConfig extends Config {
|
||||
config: GoogleInnerConfig
|
||||
}
|
||||
export interface GoogleConfig extends Config<GoogleInnerConfig> {}
|
||||
|
||||
export interface OIDCStrategyConfiguration {
|
||||
issuer: string
|
||||
|
@ -96,9 +90,7 @@ export interface OIDCInnerConfig {
|
|||
scopes: string[]
|
||||
}
|
||||
|
||||
export interface OIDCConfig extends Config {
|
||||
config: OIDCConfigs
|
||||
}
|
||||
export interface OIDCConfig extends Config<OIDCConfigs> {}
|
||||
|
||||
export interface OIDCWellKnownConfig {
|
||||
issuer: string
|
||||
|
@ -107,6 +99,12 @@ export interface OIDCWellKnownConfig {
|
|||
userinfo_endpoint: string
|
||||
}
|
||||
|
||||
export interface SCIMInnerConfig {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface SCIMConfig extends Config<SCIMInnerConfig> {}
|
||||
|
||||
export const isSettingsConfig = (config: Config): config is SettingsConfig =>
|
||||
config.type === ConfigType.SETTINGS
|
||||
|
||||
|
@ -119,6 +117,9 @@ export const isGoogleConfig = (config: Config): config is GoogleConfig =>
|
|||
export const isOIDCConfig = (config: Config): config is OIDCConfig =>
|
||||
config.type === ConfigType.OIDC
|
||||
|
||||
export const isSCIMConfig = (config: Config): config is SCIMConfig =>
|
||||
config.type === ConfigType.SCIM
|
||||
|
||||
export enum ConfigType {
|
||||
SETTINGS = "settings",
|
||||
ACCOUNT = "account",
|
||||
|
@ -126,4 +127,5 @@ export enum ConfigType {
|
|||
GOOGLE = "google",
|
||||
OIDC = "oidc",
|
||||
OIDC_LOGOS = "logos_oidc",
|
||||
SCIM = "scim",
|
||||
}
|
||||
|
|
|
@ -53,6 +53,12 @@ export interface User extends Document {
|
|||
dayPassRecordedAt?: string
|
||||
userGroups?: string[]
|
||||
onboardedAt?: string
|
||||
scimInfo?: {
|
||||
isSync: boolean
|
||||
userName: string
|
||||
externalId: string
|
||||
displayName?: string
|
||||
}
|
||||
}
|
||||
|
||||
export enum UserStatus {
|
||||
|
|
|
@ -7,6 +7,10 @@ export interface UserGroup extends Document {
|
|||
users?: GroupUser[]
|
||||
roles?: UserGroupRoles
|
||||
createdAt?: number
|
||||
scimInfo?: {
|
||||
externalId: string
|
||||
isSync: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface GroupUser {
|
||||
|
|
|
@ -5,6 +5,7 @@ import { Writable } from "stream"
|
|||
export enum SearchIndex {
|
||||
ROWS = "rows",
|
||||
AUDIT = "audit",
|
||||
USER = "user",
|
||||
}
|
||||
|
||||
export type PouchOptions = {
|
||||
|
|
|
@ -194,9 +194,9 @@ export enum Event {
|
|||
// a user facing event or not.
|
||||
export const AuditedEventFriendlyName: Record<Event, string | undefined> = {
|
||||
// USER
|
||||
[Event.USER_CREATED]: `User "{{ email }}" created`,
|
||||
[Event.USER_UPDATED]: `User "{{ email }}" updated`,
|
||||
[Event.USER_DELETED]: `User "{{ email }}" deleted`,
|
||||
[Event.USER_CREATED]: `User "{{ email }}" created{{#if viaScim}} via SCIM{{/if}}`,
|
||||
[Event.USER_UPDATED]: `User "{{ email }}" updated{{#if viaScim}} via SCIM{{/if}}`,
|
||||
[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_REMOVED]: `User "{{ email }}" admin role removed`,
|
||||
[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_RESET_REQUESTED]: `User "{{ email }}" password reset requested`,
|
||||
[Event.USER_PASSWORD_RESET]: `User "{{ email }}" password reset`,
|
||||
[Event.USER_GROUP_CREATED]: `User group "{{ name }}" created`,
|
||||
[Event.USER_GROUP_UPDATED]: `User group "{{ name }}" updated`,
|
||||
[Event.USER_GROUP_DELETED]: `User group "{{ name }}" deleted`,
|
||||
[Event.USER_GROUP_USERS_ADDED]: `User group "{{ name }}" {{ count }} users added`,
|
||||
[Event.USER_GROUP_USERS_REMOVED]: `User group "{{ name }}" {{ count }} users removed`,
|
||||
[Event.USER_GROUP_CREATED]: `User group "{{ name }}" created{{#if viaScim}} via SCIM{{/if}}`,
|
||||
[Event.USER_GROUP_UPDATED]: `User group "{{ name }}" updated{{#if viaScim}} via SCIM{{/if}}`,
|
||||
[Event.USER_GROUP_DELETED]: `User group "{{ name }}" deleted{{#if viaScim}} via SCIM{{/if}}`,
|
||||
[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{{#if viaScim}} via SCIM{{/if}}`,
|
||||
[Event.USER_GROUP_PERMISSIONS_EDITED]: `User group "{{ name }}" permissions edited`,
|
||||
[Event.USER_PASSWORD_FORCE_RESET]: undefined,
|
||||
[Event.USER_GROUP_ONBOARDING]: undefined,
|
||||
|
|
|
@ -2,6 +2,7 @@ import { BaseEvent } from "./event"
|
|||
|
||||
export interface UserCreatedEvent extends BaseEvent {
|
||||
userId: string
|
||||
viaScim?: boolean
|
||||
audited: {
|
||||
email: string
|
||||
}
|
||||
|
@ -9,6 +10,7 @@ export interface UserCreatedEvent extends BaseEvent {
|
|||
|
||||
export interface UserUpdatedEvent extends BaseEvent {
|
||||
userId: string
|
||||
viaScim?: boolean
|
||||
audited: {
|
||||
email: string
|
||||
}
|
||||
|
@ -16,6 +18,7 @@ export interface UserUpdatedEvent extends BaseEvent {
|
|||
|
||||
export interface UserDeletedEvent extends BaseEvent {
|
||||
userId: string
|
||||
viaScim?: boolean
|
||||
audited: {
|
||||
email: string
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import { BaseEvent } from "./event"
|
|||
|
||||
export interface GroupCreatedEvent extends BaseEvent {
|
||||
groupId: string
|
||||
viaScim?: boolean
|
||||
audited: {
|
||||
name: string
|
||||
}
|
||||
|
@ -9,6 +10,7 @@ export interface GroupCreatedEvent extends BaseEvent {
|
|||
|
||||
export interface GroupUpdatedEvent extends BaseEvent {
|
||||
groupId: string
|
||||
viaScim?: boolean
|
||||
audited: {
|
||||
name: string
|
||||
}
|
||||
|
@ -16,6 +18,7 @@ export interface GroupUpdatedEvent extends BaseEvent {
|
|||
|
||||
export interface GroupDeletedEvent extends BaseEvent {
|
||||
groupId: string
|
||||
viaScim?: boolean
|
||||
audited: {
|
||||
name: string
|
||||
}
|
||||
|
@ -24,6 +27,7 @@ export interface GroupDeletedEvent extends BaseEvent {
|
|||
export interface GroupUsersAddedEvent extends BaseEvent {
|
||||
count: number
|
||||
groupId: string
|
||||
viaScim?: boolean
|
||||
audited: {
|
||||
name: string
|
||||
}
|
||||
|
@ -32,6 +36,7 @@ export interface GroupUsersAddedEvent extends BaseEvent {
|
|||
export interface GroupUsersDeletedEvent extends BaseEvent {
|
||||
count: number
|
||||
groupId: string
|
||||
viaScim?: boolean
|
||||
audited: {
|
||||
name: string
|
||||
}
|
||||
|
|
|
@ -5,4 +5,5 @@ export enum Feature {
|
|||
AUDIT_LOGS = "auditLogs",
|
||||
ENFORCEABLE_SSO = "enforceableSSO",
|
||||
BRANDING = "branding",
|
||||
SCIM = "scim",
|
||||
}
|
||||
|
|
|
@ -487,6 +487,11 @@ escalade@^3.1.1:
|
|||
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
|
||||
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:
|
||||
version "1.15.2"
|
||||
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"
|
||||
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:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
|
||||
|
|
|
@ -79,6 +79,7 @@
|
|||
"@types/jsonwebtoken": "8.5.1",
|
||||
"@types/koa": "2.13.4",
|
||||
"@types/koa__router": "8.0.8",
|
||||
"@types/lodash": "^4.14.191",
|
||||
"@types/node": "14.18.20",
|
||||
"@types/node-fetch": "2.6.1",
|
||||
"@types/pouchdb": "6.4.0",
|
||||
|
@ -89,6 +90,7 @@
|
|||
"copyfiles": "2.4.1",
|
||||
"eslint": "6.8.0",
|
||||
"jest": "28.1.1",
|
||||
"lodash": "4.17.21",
|
||||
"nodemon": "2.0.15",
|
||||
"pouchdb-adapter-memory": "7.2.2",
|
||||
"prettier": "2.3.1",
|
||||
|
|
|
@ -177,7 +177,7 @@ export const destroy = async (ctx: any) => {
|
|||
ctx.throw(400, "Unable to delete self.")
|
||||
}
|
||||
|
||||
await userSdk.destroy(id, ctx.user)
|
||||
await userSdk.destroy(id)
|
||||
|
||||
ctx.body = {
|
||||
message: `User ${id} deleted.`,
|
||||
|
@ -197,7 +197,7 @@ export const search = async (ctx: any) => {
|
|||
if (body.paginated === false) {
|
||||
await getAppUsers(ctx)
|
||||
} else {
|
||||
const paginated = await userSdk.paginatedUsers(body)
|
||||
const paginated = await userSdk.core.paginatedUsers(body)
|
||||
// user hashed password shouldn't ever be returned
|
||||
for (let user of paginated.data) {
|
||||
if (user) {
|
||||
|
|
|
@ -58,6 +58,13 @@ function oidcValidation() {
|
|||
}).unknown(true)
|
||||
}
|
||||
|
||||
function scimValidation() {
|
||||
// prettier-ignore
|
||||
return Joi.object({
|
||||
enabled: Joi.boolean().required(),
|
||||
}).unknown(true)
|
||||
}
|
||||
|
||||
function buildConfigSaveValidation() {
|
||||
// prettier-ignore
|
||||
return auth.joiValidator.body(Joi.object({
|
||||
|
@ -74,7 +81,8 @@ function buildConfigSaveValidation() {
|
|||
{ is: ConfigType.SETTINGS, then: settingValidation() },
|
||||
{ is: ConfigType.ACCOUNT, then: Joi.object().unknown(true) },
|
||||
{ is: ConfigType.GOOGLE, then: googleValidation() },
|
||||
{ is: ConfigType.OIDC, then: oidcValidation() }
|
||||
{ is: ConfigType.OIDC, then: oidcValidation() },
|
||||
{ is: ConfigType.SCIM, then: scimValidation() }
|
||||
],
|
||||
}),
|
||||
}).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 { api } from "@budibase/pro"
|
||||
import { api as pro } from "@budibase/pro"
|
||||
import userRoutes from "./global/users"
|
||||
import configRoutes from "./global/configs"
|
||||
import workspaceRoutes from "./global/workspaces"
|
||||
|
@ -17,9 +17,6 @@ import migrationRoutes from "./system/migrations"
|
|||
import accountRoutes from "./system/accounts"
|
||||
import restoreRoutes from "./system/restore"
|
||||
|
||||
let userGroupRoutes = api.groups
|
||||
let auditLogRoutes = api.auditLogs
|
||||
|
||||
export const routes: Router[] = [
|
||||
configRoutes,
|
||||
userRoutes,
|
||||
|
@ -33,10 +30,11 @@ export const routes: Router[] = [
|
|||
statusRoutes,
|
||||
selfRoutes,
|
||||
licenseRoutes,
|
||||
userGroupRoutes,
|
||||
auditLogRoutes,
|
||||
pro.groups,
|
||||
pro.auditLogs,
|
||||
migrationRoutes,
|
||||
accountRoutes,
|
||||
restoreRoutes,
|
||||
eventRoutes,
|
||||
pro.scim,
|
||||
]
|
||||
|
|
|
@ -35,6 +35,8 @@ const logger = require("koa-pino-logger")
|
|||
const { userAgent } = require("koa-useragent")
|
||||
|
||||
import destroyable from "server-destroy"
|
||||
import { initPro } from "./initPro"
|
||||
import { handleScimBody } from "./middleware/handleScimBody"
|
||||
|
||||
// configure events to use the pro audit log write
|
||||
// can't integrate directly into backend-core due to cyclic issues
|
||||
|
@ -54,7 +56,9 @@ const app: Application = new Koa()
|
|||
app.keys = ["secret", "key"]
|
||||
|
||||
// set up top level koa middleware
|
||||
app.use(handleScimBody)
|
||||
app.use(koaBody({ multipart: true }))
|
||||
|
||||
app.use(koaSession(app))
|
||||
app.use(middleware.logging)
|
||||
app.use(logger(logging.pinoSettings()))
|
||||
|
@ -108,6 +112,7 @@ const shutdown = () => {
|
|||
|
||||
export default server.listen(parseInt(env.PORT || "4002"), async () => {
|
||||
console.log(`Worker running on ${JSON.stringify(server.address())}`)
|
||||
await initPro()
|
||||
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,
|
||||
ViewName,
|
||||
env as coreEnv,
|
||||
context,
|
||||
} from "@budibase/backend-core"
|
||||
import {
|
||||
AccountMetadata,
|
||||
|
@ -37,8 +38,6 @@ import { EmailTemplatePurpose } from "../../constants"
|
|||
import * as pro from "@budibase/pro"
|
||||
import * as accountSdk from "../accounts"
|
||||
|
||||
const PAGE_LIMIT = 8
|
||||
|
||||
export const allUsers = async () => {
|
||||
const db = tenancy.getGlobalDB()
|
||||
const response = await db.allDocs(
|
||||
|
@ -68,43 +67,6 @@ export const getUsersByAppAccess = async (appId?: string) => {
|
|||
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) {
|
||||
return usersCore.getGlobalUserByEmail(email)
|
||||
}
|
||||
|
@ -576,7 +538,7 @@ export const bulkDelete = async (
|
|||
return response
|
||||
}
|
||||
|
||||
export const destroy = async (id: string, currentUser: any) => {
|
||||
export const destroy = async (id: string) => {
|
||||
const db = tenancy.getGlobalDB()
|
||||
const dbUser = (await db.get(id)) as User
|
||||
const userId = dbUser._id as string
|
||||
|
@ -586,7 +548,7 @@ export const destroy = async (id: string, currentUser: any) => {
|
|||
const email = dbUser.email
|
||||
const account = await accounts.getAccount(email)
|
||||
if (account) {
|
||||
if (email === currentUser.email) {
|
||||
if (dbUser.userId === context.getIdentity()!._id) {
|
||||
throw new HTTPError('Please visit "Account" to delete this user', 400)
|
||||
} else {
|
||||
throw new HTTPError("Account holder cannot be deleted", 400)
|
||||
|
|
|
@ -20,9 +20,18 @@ import {
|
|||
auth,
|
||||
constants,
|
||||
env as coreEnv,
|
||||
db as dbCore,
|
||||
encryption,
|
||||
utils,
|
||||
} from "@budibase/backend-core"
|
||||
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"
|
||||
|
||||
class TestConfiguration {
|
||||
|
@ -31,6 +40,7 @@ class TestConfiguration {
|
|||
api: API
|
||||
tenantId: string
|
||||
user?: User
|
||||
apiKey?: string
|
||||
userPassword = "test"
|
||||
|
||||
constructor(opts: { openServer: boolean } = { openServer: true }) {
|
||||
|
@ -49,6 +59,12 @@ class TestConfiguration {
|
|||
this.api = new API(this)
|
||||
}
|
||||
|
||||
async useNewTenant() {
|
||||
this.tenantId = structures.tenant.id()
|
||||
|
||||
await this.beforeAll()
|
||||
}
|
||||
|
||||
getRequest() {
|
||||
return this.request
|
||||
}
|
||||
|
@ -201,6 +217,12 @@ class TestConfiguration {
|
|||
return { [constants.Header.API_KEY]: coreEnv.INTERNAL_API_KEY }
|
||||
}
|
||||
|
||||
bearerAPIHeaders() {
|
||||
return {
|
||||
[constants.Header.AUTHORIZATION]: `Bearer ${this.apiKey}`,
|
||||
}
|
||||
}
|
||||
|
||||
adminOnlyResponse = () => {
|
||||
return { message: "Admin user only endpoint.", status: 403 }
|
||||
}
|
||||
|
@ -213,6 +235,20 @@ class TestConfiguration {
|
|||
})
|
||||
await context.doInTenant(this.tenantId!, async () => {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
|
@ -15,6 +15,9 @@ import { RolesAPI } from "./roles"
|
|||
import { TemplatesAPI } from "./templates"
|
||||
import { LicenseAPI } from "./license"
|
||||
import { AuditLogAPI } from "./auditLogs"
|
||||
import { ScimUsersAPI } from "./scim/users"
|
||||
import { ScimGroupsAPI } from "./scim/groups"
|
||||
|
||||
export default class API {
|
||||
accounts: AccountAPI
|
||||
auth: AuthAPI
|
||||
|
@ -32,6 +35,8 @@ export default class API {
|
|||
templates: TemplatesAPI
|
||||
license: LicenseAPI
|
||||
auditLogs: AuditLogAPI
|
||||
scimUsersAPI: ScimUsersAPI
|
||||
scimGroupsAPI: ScimGroupsAPI
|
||||
|
||||
constructor(config: TestConfiguration) {
|
||||
this.accounts = new AccountAPI(config)
|
||||
|
@ -50,5 +55,7 @@ export default class API {
|
|||
this.templates = new TemplatesAPI(config)
|
||||
this.license = new LicenseAPI(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:
|
||||
"@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":
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
|
||||
|
|
Loading…
Reference in New Issue