1
0
Fork 0
mirror of synced 2024-07-02 21:10:43 +12:00

Merge pull request #10022 from Budibase/feature/scim

Feature - SCIM endpoints
This commit is contained in:
Adria Navarro 2023-04-03 13:22:01 +02:00 committed by GitHub
commit e1669c8260
61 changed files with 2228 additions and 212 deletions

View file

@ -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
}

View file

@ -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)

View file

@ -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
}

View file

@ -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)
})
})
})

View file

@ -6,4 +6,5 @@ export type ContextMap = {
appId?: string
identity?: IdentityContext
environmentVariables?: Record<string, string>
isScim?: boolean
}

View file

@ -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"

View file

@ -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)

View file

@ -0,0 +1 @@
export * from "./searchIndexes"

View file

@ -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)
}

View file

@ -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", () => {

View file

@ -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",

View file

@ -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,
},

View file

@ -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,
},

View file

@ -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,

View file

@ -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,
})
}

View file

@ -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) => {

View file

@ -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"

View file

@ -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
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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}

View file

@ -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()

View file

@ -14,3 +14,4 @@ export { overview } from "./overview"
export { environment } from "./environment"
export { menu } from "./menu"
export { auditLogs } from "./auditLogs"
export { features } from "./features"

View file

@ -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,

View file

@ -69,6 +69,7 @@ export const Features = {
AUDIT_LOGS: "auditLogs",
ENFORCEABLE_SSO: "enforceableSSO",
BRANDING: "branding",
SCIM: "scim",
}
// Role IDs

View file

@ -29,5 +29,8 @@
"koa-body": "4.2.0",
"rimraf": "3.0.2",
"typescript": "4.7.3"
},
"dependencies": {
"scim-patch": "^0.7.0"
}
}

View file

@ -2,3 +2,4 @@ export * from "./environmentVariables"
export * from "./auditLogs"
export * from "./events"
export * from "./configs"
export * from "./scim"

View file

@ -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> {}

View file

@ -0,0 +1,3 @@
export * from "./users"
export * from "./groups"
export * from "./shared"

View file

@ -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[]
}

View file

@ -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> {}

View file

@ -22,6 +22,6 @@ export interface PaginationRequest extends BasicPaginationRequest {
}
export interface PaginationResponse {
bookmark: string
bookmark: string | undefined
hasNextPage: boolean
}

View file

@ -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",
}

View file

@ -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 {

View file

@ -7,6 +7,10 @@ export interface UserGroup extends Document {
users?: GroupUser[]
roles?: UserGroupRoles
createdAt?: number
scimInfo?: {
externalId: string
isSync: boolean
}
}
export interface GroupUser {

View file

@ -5,6 +5,7 @@ import { Writable } from "stream"
export enum SearchIndex {
ROWS = "rows",
AUDIT = "audit",
USER = "user",
}
export type PouchOptions = {

View file

@ -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,

View file

@ -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
}

View file

@ -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
}

View file

@ -5,4 +5,5 @@ export enum Feature {
AUDIT_LOGS = "auditLogs",
ENFORCEABLE_SSO = "enforceableSSO",
BRANDING = "branding",
SCIM = "scim",
}

View file

@ -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"

View file

@ -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",

View file

@ -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) {

View file

@ -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),

View file

@ -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)
})
})
})
})
})

View file

@ -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,
]

View file

@ -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()
})

View file

@ -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),
},
},
})
}

View file

@ -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()
}

View file

@ -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)

View file

@ -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

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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"