@budibase/worker 3.28.3 → 3.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/api/controllers/global/auditLogs.ts +38 -0
- package/src/api/controllers/global/groups.ts +212 -0
- package/src/api/controllers/global/scim/groups.ts +157 -0
- package/src/api/controllers/global/scim/users.ts +124 -0
- package/src/api/routes/global/auditLogs.ts +46 -0
- package/src/api/routes/global/groups.ts +88 -0
- package/src/api/routes/global/scim.ts +53 -0
- package/src/api/routes/global/tests/users.spec.ts +18 -0
- package/src/api/routes/index.ts +6 -4
- package/src/sdk/users/users.ts +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@budibase/worker",
|
|
3
3
|
"email": "hi@budibase.com",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.30.0",
|
|
5
5
|
"description": "Budibase background service",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"repository": {
|
|
@@ -66,6 +66,8 @@
|
|
|
66
66
|
"passport-local": "1.0.0",
|
|
67
67
|
"pouchdb": "9.0.0",
|
|
68
68
|
"pouchdb-all-dbs": "1.1.1",
|
|
69
|
+
"scim-patch": "^0.8.1",
|
|
70
|
+
"scim2-parse-filter": "^0.2.8",
|
|
69
71
|
"server-destroy": "1.0.1",
|
|
70
72
|
"undici": "^7.16.0",
|
|
71
73
|
"uuid": "^8.3.2",
|
|
@@ -107,5 +109,5 @@
|
|
|
107
109
|
}
|
|
108
110
|
}
|
|
109
111
|
},
|
|
110
|
-
"gitHead": "
|
|
112
|
+
"gitHead": "58456cc695d2c71a354bcf5c064943b247ad4969"
|
|
111
113
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Readable } from "stream"
|
|
2
|
+
import { events } from "@budibase/backend-core"
|
|
3
|
+
import { auditLogs } from "@budibase/pro"
|
|
4
|
+
import {
|
|
5
|
+
SearchAuditLogsRequest,
|
|
6
|
+
SearchAuditLogsResponse,
|
|
7
|
+
DownloadAuditLogsRequest,
|
|
8
|
+
DefinitionsAuditLogsResponse,
|
|
9
|
+
AuditLogSearchParams,
|
|
10
|
+
UserCtx,
|
|
11
|
+
} from "@budibase/types"
|
|
12
|
+
|
|
13
|
+
export async function search(
|
|
14
|
+
ctx: UserCtx<SearchAuditLogsRequest, SearchAuditLogsResponse>
|
|
15
|
+
) {
|
|
16
|
+
const search: AuditLogSearchParams = ctx.request.body
|
|
17
|
+
const fetched = await auditLogs.fetch(search)
|
|
18
|
+
await events.auditLog.filtered(search)
|
|
19
|
+
ctx.body = fetched
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function download(
|
|
23
|
+
ctx: UserCtx<DownloadAuditLogsRequest, Readable>
|
|
24
|
+
) {
|
|
25
|
+
const search: AuditLogSearchParams = ctx.request.body
|
|
26
|
+
const { stream } = auditLogs.download(search)
|
|
27
|
+
await events.auditLog.downloaded(search)
|
|
28
|
+
ctx.attachment(`audit-logs-${Date.now()}.log`)
|
|
29
|
+
ctx.body = stream
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function definitions(
|
|
33
|
+
ctx: UserCtx<void, DefinitionsAuditLogsResponse>
|
|
34
|
+
) {
|
|
35
|
+
ctx.body = {
|
|
36
|
+
events: auditLogs.definitions(),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { csv } from "@budibase/backend-core"
|
|
2
|
+
import {
|
|
3
|
+
BulkAddUsersToGroupRequest,
|
|
4
|
+
BulkAddUsersToGroupResponse,
|
|
5
|
+
Ctx,
|
|
6
|
+
DatabaseQueryOpts,
|
|
7
|
+
SearchGroupRequest,
|
|
8
|
+
SearchGroupResponse,
|
|
9
|
+
SearchUserGroupResponse,
|
|
10
|
+
UpdateGroupAppRequest,
|
|
11
|
+
UpdateGroupAppResponse,
|
|
12
|
+
UserCtx,
|
|
13
|
+
UserGroup,
|
|
14
|
+
} from "@budibase/types"
|
|
15
|
+
import { db, groups, users as usersSdk } from "@budibase/pro"
|
|
16
|
+
|
|
17
|
+
export async function save(ctx: UserCtx) {
|
|
18
|
+
const group: UserGroup = ctx.request.body
|
|
19
|
+
group.name = group.name.trim()
|
|
20
|
+
|
|
21
|
+
// don't allow updating the roles through this endpoint
|
|
22
|
+
delete group.roles
|
|
23
|
+
if (group._id) {
|
|
24
|
+
const oldGroup = await groups.get(group._id)
|
|
25
|
+
group.roles = oldGroup.roles
|
|
26
|
+
group.scimInfo = oldGroup.scimInfo
|
|
27
|
+
}
|
|
28
|
+
const response = await groups.save(group)
|
|
29
|
+
ctx.body = {
|
|
30
|
+
_id: response.id,
|
|
31
|
+
_rev: response.rev,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function updateGroupUsers(ctx: UserCtx) {
|
|
36
|
+
const groupId = ctx.params.groupId
|
|
37
|
+
const toAdd = ctx.request.body.add,
|
|
38
|
+
toRemove = ctx.request.body.remove
|
|
39
|
+
if (
|
|
40
|
+
(toAdd && !Array.isArray(toAdd)) ||
|
|
41
|
+
(toRemove && !Array.isArray(toRemove))
|
|
42
|
+
) {
|
|
43
|
+
ctx.throw(400, "Must supply a list of users to add or to remove")
|
|
44
|
+
}
|
|
45
|
+
let added, removed
|
|
46
|
+
if (toAdd) {
|
|
47
|
+
added = await groups.addUsers(groupId, toAdd)
|
|
48
|
+
}
|
|
49
|
+
if (toRemove) {
|
|
50
|
+
removed = await groups.removeUsers(groupId, toRemove)
|
|
51
|
+
}
|
|
52
|
+
ctx.body = { added, removed }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function updateGroupApps(
|
|
56
|
+
ctx: UserCtx<UpdateGroupAppRequest, UpdateGroupAppResponse>
|
|
57
|
+
) {
|
|
58
|
+
const groupId = ctx.params.groupId
|
|
59
|
+
const toAdd = ctx.request.body.add,
|
|
60
|
+
toRemove = ctx.request.body.remove
|
|
61
|
+
if (
|
|
62
|
+
(toAdd && !Array.isArray(toAdd)) ||
|
|
63
|
+
(toRemove && !Array.isArray(toRemove))
|
|
64
|
+
) {
|
|
65
|
+
ctx.throw(
|
|
66
|
+
400,
|
|
67
|
+
"Must supply a list of objects, with appId and roleId to add or remove"
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
ctx.body = await groups.updateGroupApps(groupId, {
|
|
71
|
+
appsToAdd: toAdd,
|
|
72
|
+
appsToRemove: toRemove,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function fetch(ctx: Ctx<SearchGroupRequest, SearchGroupResponse>) {
|
|
77
|
+
ctx.body = { data: await groups.fetch() }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function destroy(ctx: UserCtx) {
|
|
81
|
+
const { groupId, rev } = ctx.params
|
|
82
|
+
try {
|
|
83
|
+
await groups.remove(groupId, rev)
|
|
84
|
+
ctx.body = { message: "Group deleted successfully" }
|
|
85
|
+
} catch (err: any) {
|
|
86
|
+
ctx.throw(err.status, err)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Gets a group by ID from the global database.
|
|
92
|
+
*/
|
|
93
|
+
export async function find(ctx: UserCtx) {
|
|
94
|
+
try {
|
|
95
|
+
ctx.body = await groups.get(ctx.params.groupId)
|
|
96
|
+
} catch (err: any) {
|
|
97
|
+
ctx.throw(err.status, err)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function searchUsers(ctx: Ctx<{}, SearchUserGroupResponse>) {
|
|
102
|
+
const { pageSize = 10, bookmark, emailSearch } = ctx.request.query as any
|
|
103
|
+
const groupId = ctx.params.groupId
|
|
104
|
+
|
|
105
|
+
const params: DatabaseQueryOpts = { limit: pageSize + 1 }
|
|
106
|
+
|
|
107
|
+
const users = await db.groups.getGroupUsers(groupId, {
|
|
108
|
+
...params,
|
|
109
|
+
emailSearch,
|
|
110
|
+
bookmark,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const nextBookmark = emailSearch
|
|
114
|
+
? users[pageSize]?.email
|
|
115
|
+
: users[pageSize]?._id
|
|
116
|
+
const hasNextPage = !!nextBookmark
|
|
117
|
+
|
|
118
|
+
ctx.body = {
|
|
119
|
+
users: users.slice(0, pageSize),
|
|
120
|
+
bookmark: nextBookmark,
|
|
121
|
+
hasNextPage,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function bulkAddUsersFromCsv(
|
|
126
|
+
ctx: UserCtx<BulkAddUsersToGroupRequest, BulkAddUsersToGroupResponse>
|
|
127
|
+
) {
|
|
128
|
+
const { groupId } = ctx.params
|
|
129
|
+
const { csvContent } = ctx.request.body
|
|
130
|
+
|
|
131
|
+
if (csvContent === undefined || csvContent.trim().length === 0) {
|
|
132
|
+
ctx.throw(400, "CSV is empty")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const csvData = await csv.jsonFromCsvString(csvContent, {
|
|
136
|
+
allowSingleColumn: true,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
if (!csvData || csvData.length === 0) {
|
|
140
|
+
ctx.throw(400, "CSV file is invalid")
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Find email column
|
|
144
|
+
const headers = Object.keys(csvData[0])
|
|
145
|
+
const emailColumn = headers.find(header =>
|
|
146
|
+
/^(email|e-mail|email address|mail|e_mail)$/i.test(header.trim())
|
|
147
|
+
)
|
|
148
|
+
if (!emailColumn) {
|
|
149
|
+
ctx.throw(400, "CSV file must contain an email column")
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Extract emails from CSV
|
|
153
|
+
const emails = Array.from(
|
|
154
|
+
new Set(
|
|
155
|
+
csvData
|
|
156
|
+
.map(row => row[emailColumn])
|
|
157
|
+
.filter(
|
|
158
|
+
email => email && typeof email === "string" && email.trim().length > 0
|
|
159
|
+
)
|
|
160
|
+
.map(email => email.trim())
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
if (emails.length === 0) {
|
|
164
|
+
ctx.throw(400, "No valid email addresses found in CSV")
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Check if group exists
|
|
168
|
+
try {
|
|
169
|
+
await groups.get(groupId)
|
|
170
|
+
} catch (err: any) {
|
|
171
|
+
if (err.status === 404) {
|
|
172
|
+
ctx.throw(404, "Group not found")
|
|
173
|
+
} else {
|
|
174
|
+
throw err
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Find existing users by email
|
|
179
|
+
const added: { _id: string; email: string }[] = []
|
|
180
|
+
const skipped: { email: string; reason: string }[] = []
|
|
181
|
+
const userIds: string[] = []
|
|
182
|
+
|
|
183
|
+
const users = await Promise.all(
|
|
184
|
+
emails.map(email => usersSdk.db.getUserByEmail(email))
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
for (const email of emails) {
|
|
188
|
+
const user = users.find(u => u?.email === email)
|
|
189
|
+
if (user) {
|
|
190
|
+
added.push({
|
|
191
|
+
_id: user._id!,
|
|
192
|
+
email: user.email,
|
|
193
|
+
})
|
|
194
|
+
userIds.push(user._id!)
|
|
195
|
+
} else {
|
|
196
|
+
skipped.push({
|
|
197
|
+
email,
|
|
198
|
+
reason: "User not found",
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Add users to group if any were found
|
|
204
|
+
if (userIds.length > 0) {
|
|
205
|
+
await groups.addUsers(groupId, userIds)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
ctx.body = {
|
|
209
|
+
added,
|
|
210
|
+
skipped,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import groupBy from "lodash/groupBy"
|
|
2
|
+
import { patchBodyValidation, scimPatch } from "scim-patch"
|
|
3
|
+
import { filter, parse } from "scim2-parse-filter"
|
|
4
|
+
import {
|
|
5
|
+
Ctx,
|
|
6
|
+
ScimCreateGroupRequest,
|
|
7
|
+
ScimGroupListResponse,
|
|
8
|
+
ScimGroupResponse,
|
|
9
|
+
ScimUpdateRequest,
|
|
10
|
+
UserGroup,
|
|
11
|
+
} from "@budibase/types"
|
|
12
|
+
import { utils } from "@budibase/shared-core"
|
|
13
|
+
import { groups, mappers, scimGroups, scimUsers } from "@budibase/pro"
|
|
14
|
+
|
|
15
|
+
function cleanResponse(group: ScimGroupResponse, excludedAttributes: string) {
|
|
16
|
+
for (const attr of (excludedAttributes as string).split(",")) {
|
|
17
|
+
delete (group as any)[attr]
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const get = async (ctx: Ctx<void, ScimGroupListResponse>) => {
|
|
22
|
+
const fetchedGroups = await groups.fetch()
|
|
23
|
+
let result = fetchedGroups
|
|
24
|
+
.filter(g => g.scimInfo?.isSync)
|
|
25
|
+
.map(mappers.group.toScimGroupResponse)
|
|
26
|
+
|
|
27
|
+
const { filter: reqFilter, excludedAttributes } = ctx.request.query
|
|
28
|
+
|
|
29
|
+
if (reqFilter) {
|
|
30
|
+
const filterFunc = filter(parse(reqFilter as string))
|
|
31
|
+
result = result.filter(filterFunc)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (excludedAttributes) {
|
|
35
|
+
result.forEach((g: any) => {
|
|
36
|
+
cleanResponse(g, excludedAttributes as string)
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
ctx.body = {
|
|
41
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
42
|
+
totalResults: result.length,
|
|
43
|
+
Resources: result,
|
|
44
|
+
startIndex: 1,
|
|
45
|
+
itemsPerPage: result.length,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const create = async (
|
|
50
|
+
ctx: Ctx<ScimCreateGroupRequest, ScimGroupResponse>
|
|
51
|
+
) => {
|
|
52
|
+
const groupToCreate = mappers.group.fromScimGroup(ctx.request.body)
|
|
53
|
+
const group = await scimGroups.create(groupToCreate)
|
|
54
|
+
ctx.body = mappers.group.toScimGroupResponse(group)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const find = async (ctx: Ctx<void, ScimGroupResponse>) => {
|
|
58
|
+
const { id } = ctx.params
|
|
59
|
+
const group = await groups.get(id)
|
|
60
|
+
const response = mappers.group.toScimGroupResponse(group)
|
|
61
|
+
|
|
62
|
+
const { excludedAttributes } = ctx.request.query
|
|
63
|
+
if (excludedAttributes) {
|
|
64
|
+
cleanResponse(response, excludedAttributes as string)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
ctx.body = response
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const remove = async (ctx: Ctx) => {
|
|
71
|
+
const { id } = ctx.params
|
|
72
|
+
const existingGroup = await groups.get(id)
|
|
73
|
+
await groups.remove(id, existingGroup._rev!)
|
|
74
|
+
ctx.status = 204
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const update = async (
|
|
78
|
+
ctx: Ctx<ScimUpdateRequest, ScimGroupResponse>
|
|
79
|
+
) => {
|
|
80
|
+
const { id } = ctx.params
|
|
81
|
+
const group = await groups.get(id)
|
|
82
|
+
|
|
83
|
+
const scimGroup = mappers.group.toScimGroupResponse(group)
|
|
84
|
+
|
|
85
|
+
const patchs = ctx.request.body
|
|
86
|
+
try {
|
|
87
|
+
// Validate request
|
|
88
|
+
patchBodyValidation(patchs)
|
|
89
|
+
} catch (error) {
|
|
90
|
+
ctx.throw(400)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const { true: memberOps, false: fieldOps } = groupBy(
|
|
94
|
+
patchs.Operations,
|
|
95
|
+
p => p.path === "members"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if (fieldOps?.length) {
|
|
99
|
+
const patchedScimGroup = scimPatch(scimGroup, fieldOps)
|
|
100
|
+
if (!patchedScimGroup) {
|
|
101
|
+
ctx.throw(500)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const groupToUpdate: UserGroup = {
|
|
105
|
+
...mappers.group.fromScimGroup(patchedScimGroup),
|
|
106
|
+
_rev: group._rev,
|
|
107
|
+
}
|
|
108
|
+
await groups.save(groupToUpdate)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (memberOps?.length) {
|
|
112
|
+
const usersToAdd = []
|
|
113
|
+
const usersToRemove = []
|
|
114
|
+
for (const { op, value } of memberOps) {
|
|
115
|
+
switch (op) {
|
|
116
|
+
case "add":
|
|
117
|
+
case "Add":
|
|
118
|
+
for (const u of value) {
|
|
119
|
+
usersToAdd.push(await scimUsers.find(u.value))
|
|
120
|
+
}
|
|
121
|
+
break
|
|
122
|
+
case "remove":
|
|
123
|
+
case "Remove":
|
|
124
|
+
for (const u of value) {
|
|
125
|
+
try {
|
|
126
|
+
usersToRemove.push(await scimUsers.find(u.value))
|
|
127
|
+
} catch (e: any) {
|
|
128
|
+
if (e.status !== 404) {
|
|
129
|
+
throw e
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
break
|
|
134
|
+
case "replace":
|
|
135
|
+
case "Replace":
|
|
136
|
+
throw new Error("Replacing members is not allowed")
|
|
137
|
+
default:
|
|
138
|
+
utils.unreachable(op)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (usersToAdd.length) {
|
|
143
|
+
await groups.addUsers(
|
|
144
|
+
id,
|
|
145
|
+
usersToAdd.map(u => u._id!)
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
if (usersToRemove.length) {
|
|
149
|
+
await groups.removeUsers(
|
|
150
|
+
id,
|
|
151
|
+
usersToRemove.map(u => u._id!)
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
ctx.body = mappers.group.toScimGroupResponse(await groups.get(id))
|
|
157
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { patchBodyValidation, scimPatch } from "scim-patch"
|
|
2
|
+
import { EmailUnavailableError } from "@budibase/backend-core"
|
|
3
|
+
import { mappers, scimUsers } from "@budibase/pro"
|
|
4
|
+
import {
|
|
5
|
+
Ctx,
|
|
6
|
+
ScimUserListResponse,
|
|
7
|
+
ScimCreateUserRequest,
|
|
8
|
+
ScimUserResponse,
|
|
9
|
+
ScimUpdateRequest,
|
|
10
|
+
} from "@budibase/types"
|
|
11
|
+
|
|
12
|
+
function tryGetQueryAsNumber(ctx: Ctx, name: string) {
|
|
13
|
+
const value = ctx.request.query[name]
|
|
14
|
+
if (value === undefined) {
|
|
15
|
+
return undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return +value
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const get = async (ctx: Ctx<void, ScimUserListResponse>) => {
|
|
22
|
+
const pageSize = tryGetQueryAsNumber(ctx, "pageSize") ?? 20
|
|
23
|
+
const skip = tryGetQueryAsNumber(ctx, "startIndex")
|
|
24
|
+
|
|
25
|
+
let filters
|
|
26
|
+
if (ctx.request.query.filter) {
|
|
27
|
+
filters = mappers.user.userFilters(ctx.request.query.filter as string)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const getResponse = await scimUsers.get({ pageSize, skip, filters })
|
|
31
|
+
ctx.body = {
|
|
32
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
33
|
+
totalResults: getResponse.total,
|
|
34
|
+
Resources: getResponse.users.map(mappers.user.toScimUserResponse),
|
|
35
|
+
startIndex: (skip || 0) + 1,
|
|
36
|
+
itemsPerPage: pageSize,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const find = async (ctx: Ctx<void, ScimUserResponse>) => {
|
|
41
|
+
const { id } = ctx.params
|
|
42
|
+
if (typeof id !== "string") {
|
|
43
|
+
ctx.throw(404)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const user = await scimUsers.find(id)
|
|
47
|
+
ctx.body = mappers.user.toScimUserResponse(user)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const create = async (
|
|
51
|
+
ctx: Ctx<ScimCreateUserRequest, ScimUserResponse>
|
|
52
|
+
) => {
|
|
53
|
+
const userToCreate = mappers.user.fromScimUser(ctx.request.body)
|
|
54
|
+
try {
|
|
55
|
+
const user = await scimUsers.create(userToCreate)
|
|
56
|
+
ctx.body = mappers.user.toScimUserResponse(user)
|
|
57
|
+
} catch (e) {
|
|
58
|
+
if (e instanceof EmailUnavailableError) {
|
|
59
|
+
ctx.throw(409, "Email already in use")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
throw e
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isDeactivation(request: ScimUpdateRequest) {
|
|
67
|
+
const activeFieldChange = request.Operations.find(
|
|
68
|
+
o => (o.op === "Replace" || o.op === "replace") && o.path === "active"
|
|
69
|
+
)
|
|
70
|
+
if (!activeFieldChange) {
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
activeFieldChange.value === false ||
|
|
76
|
+
activeFieldChange.value?.toLowerCase?.() === "false"
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const update = async (ctx: Ctx<ScimUpdateRequest, ScimUserResponse>) => {
|
|
81
|
+
const user = await scimUsers.find(ctx.params.id)
|
|
82
|
+
if (!user) {
|
|
83
|
+
ctx.throw(404)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const scimUser = mappers.user.toScimUserResponse(user)
|
|
87
|
+
|
|
88
|
+
const patchs = ctx.request.body
|
|
89
|
+
try {
|
|
90
|
+
patchBodyValidation(patchs)
|
|
91
|
+
} catch (error) {
|
|
92
|
+
// Here if there are an error in you SCIM request.
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (isDeactivation(patchs)) {
|
|
96
|
+
return remove(ctx)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let patchedScimUser
|
|
100
|
+
try {
|
|
101
|
+
patchedScimUser = scimPatch(scimUser, patchs.Operations)
|
|
102
|
+
} catch (error) {
|
|
103
|
+
// Here if there is an error during the patch.
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!patchedScimUser) {
|
|
107
|
+
ctx.throw(500)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const userToUpdate = mappers.user.fromScimUser(patchedScimUser)
|
|
111
|
+
await scimUsers.update(userToUpdate, { allowChangingEmail: true })
|
|
112
|
+
|
|
113
|
+
ctx.body = mappers.user.toScimUserResponse(userToUpdate)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const remove = async (ctx: Ctx) => {
|
|
117
|
+
const { id } = ctx.params
|
|
118
|
+
if (typeof id !== "string") {
|
|
119
|
+
ctx.throw(404)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await scimUsers.remove(id)
|
|
123
|
+
ctx.status = 204
|
|
124
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import Router from "@koa/router"
|
|
2
|
+
import Joi from "joi"
|
|
3
|
+
import { auth, middleware } from "@budibase/backend-core"
|
|
4
|
+
import { Event } from "@budibase/types"
|
|
5
|
+
import * as controllers from "../../controllers/global/auditLogs"
|
|
6
|
+
|
|
7
|
+
function buildAuditLogSearchValidator() {
|
|
8
|
+
return auth.joiValidator.body(
|
|
9
|
+
Joi.object({
|
|
10
|
+
userIds: Joi.array().items(Joi.string()).optional(),
|
|
11
|
+
appIds: Joi.array().items(Joi.string()).optional(),
|
|
12
|
+
events: Joi.array()
|
|
13
|
+
.items(Joi.string().valid(...Object.values(Event)))
|
|
14
|
+
.optional(),
|
|
15
|
+
startDate: Joi.string().optional().allow(""),
|
|
16
|
+
endDate: Joi.string().optional().allow(""),
|
|
17
|
+
fullSearch: Joi.string().optional().allow(""),
|
|
18
|
+
bookmark: Joi.number(),
|
|
19
|
+
})
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const router: Router = new Router()
|
|
24
|
+
|
|
25
|
+
router
|
|
26
|
+
.post(
|
|
27
|
+
"/api/global/auditlogs/search",
|
|
28
|
+
auth.adminOnly,
|
|
29
|
+
buildAuditLogSearchValidator(),
|
|
30
|
+
controllers.search
|
|
31
|
+
)
|
|
32
|
+
.get(
|
|
33
|
+
"/api/global/auditlogs/download",
|
|
34
|
+
auth.adminOnly,
|
|
35
|
+
// convert query string param to body
|
|
36
|
+
middleware.querystringToBody,
|
|
37
|
+
buildAuditLogSearchValidator(),
|
|
38
|
+
controllers.download
|
|
39
|
+
)
|
|
40
|
+
.get(
|
|
41
|
+
"/api/global/auditlogs/definitions",
|
|
42
|
+
auth.adminOnly,
|
|
43
|
+
controllers.definitions
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
export default router
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import Router from "@koa/router"
|
|
2
|
+
import Joi from "joi"
|
|
3
|
+
import { auth } from "@budibase/backend-core"
|
|
4
|
+
import { middleware as proMiddleware } from "@budibase/pro"
|
|
5
|
+
import { Feature } from "@budibase/types"
|
|
6
|
+
import * as controller from "../../controllers/global/groups"
|
|
7
|
+
|
|
8
|
+
const router: Router = new Router()
|
|
9
|
+
|
|
10
|
+
function buildGroupSaveValidation() {
|
|
11
|
+
return auth.joiValidator.body(
|
|
12
|
+
Joi.object({
|
|
13
|
+
_id: Joi.string().optional(),
|
|
14
|
+
_rev: Joi.string().optional(),
|
|
15
|
+
color: Joi.string().required(),
|
|
16
|
+
icon: Joi.string().required(),
|
|
17
|
+
name: Joi.string().trim().required().max(50),
|
|
18
|
+
role: Joi.string().optional(),
|
|
19
|
+
users: Joi.array().optional(),
|
|
20
|
+
apps: Joi.array().optional(),
|
|
21
|
+
roles: Joi.object().optional(),
|
|
22
|
+
createdAt: Joi.string().optional(),
|
|
23
|
+
updatedAt: Joi.string().optional(),
|
|
24
|
+
}).required()
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
router
|
|
29
|
+
.post(
|
|
30
|
+
"/api/global/groups",
|
|
31
|
+
auth.adminOnly,
|
|
32
|
+
proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
|
|
33
|
+
buildGroupSaveValidation(),
|
|
34
|
+
controller.save
|
|
35
|
+
)
|
|
36
|
+
.get(
|
|
37
|
+
"/api/global/groups",
|
|
38
|
+
proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
|
|
39
|
+
controller.fetch
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
.delete(
|
|
43
|
+
"/api/global/groups/:groupId/:rev",
|
|
44
|
+
proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
|
|
45
|
+
auth.adminOnly,
|
|
46
|
+
proMiddleware.internalGroupOnly("groupId"),
|
|
47
|
+
controller.destroy
|
|
48
|
+
)
|
|
49
|
+
.get(
|
|
50
|
+
"/api/global/groups/:groupId",
|
|
51
|
+
proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
|
|
52
|
+
auth.builderOrAdmin,
|
|
53
|
+
controller.find
|
|
54
|
+
)
|
|
55
|
+
.get(
|
|
56
|
+
"/api/global/groups/:groupId/users",
|
|
57
|
+
proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
|
|
58
|
+
auth.builderOrAdmin,
|
|
59
|
+
controller.searchUsers
|
|
60
|
+
)
|
|
61
|
+
// these endpoints adjust existing groups
|
|
62
|
+
.post(
|
|
63
|
+
"/api/global/groups/:groupId/users",
|
|
64
|
+
auth.adminOnly,
|
|
65
|
+
proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
|
|
66
|
+
proMiddleware.internalGroupOnly("groupId"),
|
|
67
|
+
controller.updateGroupUsers
|
|
68
|
+
)
|
|
69
|
+
.post(
|
|
70
|
+
"/api/global/groups/:groupId/users/bulk",
|
|
71
|
+
auth.adminOnly,
|
|
72
|
+
proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
|
|
73
|
+
proMiddleware.internalGroupOnly("groupId"),
|
|
74
|
+
auth.joiValidator.body(
|
|
75
|
+
Joi.object({
|
|
76
|
+
csvContent: Joi.string().required(),
|
|
77
|
+
}).required()
|
|
78
|
+
),
|
|
79
|
+
controller.bulkAddUsersFromCsv
|
|
80
|
+
)
|
|
81
|
+
.post(
|
|
82
|
+
"/api/global/groups/:groupId/apps",
|
|
83
|
+
auth.builderOrAdmin,
|
|
84
|
+
proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
|
|
85
|
+
controller.updateGroupApps
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
export default router
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import Router from "@koa/router"
|
|
2
|
+
import { middleware as proMiddleware } from "@budibase/pro"
|
|
3
|
+
import { Feature } from "@budibase/types"
|
|
4
|
+
import * as userController from "../../controllers/global/scim/users"
|
|
5
|
+
import * as groupController from "../../controllers/global/scim/groups"
|
|
6
|
+
|
|
7
|
+
const router: Router = new Router({
|
|
8
|
+
prefix: "/api/global/scim/v2",
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
router.use(proMiddleware.requireSCIM)
|
|
12
|
+
router.use(proMiddleware.doInScimContext)
|
|
13
|
+
|
|
14
|
+
router.get("/users", userController.get)
|
|
15
|
+
router.get("/users/:id", proMiddleware.scimUserOnly("id"), userController.find)
|
|
16
|
+
router.post("/users", userController.create)
|
|
17
|
+
router.patch(
|
|
18
|
+
"/users/:id",
|
|
19
|
+
proMiddleware.scimUserOnly("id"),
|
|
20
|
+
userController.update
|
|
21
|
+
)
|
|
22
|
+
router.delete(
|
|
23
|
+
"/users/:id",
|
|
24
|
+
proMiddleware.scimUserOnly("id"),
|
|
25
|
+
userController.remove
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
router.get("/groups", groupController.get)
|
|
29
|
+
router.post(
|
|
30
|
+
"/groups",
|
|
31
|
+
proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
|
|
32
|
+
groupController.create
|
|
33
|
+
)
|
|
34
|
+
router.get(
|
|
35
|
+
"/groups/:id",
|
|
36
|
+
proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
|
|
37
|
+
proMiddleware.scimGroupOnly("id"),
|
|
38
|
+
groupController.find
|
|
39
|
+
)
|
|
40
|
+
router.delete(
|
|
41
|
+
"/groups/:id",
|
|
42
|
+
proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
|
|
43
|
+
proMiddleware.scimGroupOnly("id"),
|
|
44
|
+
groupController.remove
|
|
45
|
+
)
|
|
46
|
+
router.patch(
|
|
47
|
+
"/groups/:id",
|
|
48
|
+
proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
|
|
49
|
+
proMiddleware.scimGroupOnly("id"),
|
|
50
|
+
groupController.update
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
export default router
|
|
@@ -87,6 +87,24 @@ describe("/api/global/users", () => {
|
|
|
87
87
|
expect(events.user.invited).toHaveBeenCalledTimes(0)
|
|
88
88
|
})
|
|
89
89
|
|
|
90
|
+
it("should not invite the same user twice when email casing differs", async () => {
|
|
91
|
+
const email = structures.users.newEmail().toLowerCase()
|
|
92
|
+
await config.api.users.sendUserInvite(sendMailMock, email)
|
|
93
|
+
|
|
94
|
+
jest.clearAllMocks()
|
|
95
|
+
|
|
96
|
+
const { code, res } = await config.api.users.sendUserInvite(
|
|
97
|
+
sendMailMock,
|
|
98
|
+
email.toUpperCase(),
|
|
99
|
+
400
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
expect(res.body.message).toBe(`Unavailable`)
|
|
103
|
+
expect(sendMailMock).toHaveBeenCalledTimes(0)
|
|
104
|
+
expect(code).toBeUndefined()
|
|
105
|
+
expect(events.user.invited).toHaveBeenCalledTimes(0)
|
|
106
|
+
})
|
|
107
|
+
|
|
90
108
|
it("should not allow creator users to access single invite endpoint", async () => {
|
|
91
109
|
const user = await createBuilderUser()
|
|
92
110
|
|
package/src/api/routes/index.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { api as pro } from "@budibase/pro"
|
|
2
1
|
import Router from "@koa/router"
|
|
3
2
|
import { endpointGroupList } from "./endpointGroups"
|
|
4
3
|
|
|
@@ -19,6 +18,9 @@ import "./system/logs"
|
|
|
19
18
|
import "./system/restore"
|
|
20
19
|
import "./system/status"
|
|
21
20
|
import "./system/tenants"
|
|
21
|
+
import auditLogsRoutes from "./global/auditLogs"
|
|
22
|
+
import groupRoutes from "./global/groups"
|
|
23
|
+
import scimRoutes from "./global/scim"
|
|
22
24
|
|
|
23
25
|
const endpointGroupsRouter = new Router()
|
|
24
26
|
for (let endpoint of endpointGroupList.listAllEndpoints()) {
|
|
@@ -27,7 +29,7 @@ for (let endpoint of endpointGroupList.listAllEndpoints()) {
|
|
|
27
29
|
|
|
28
30
|
export const routes: Router[] = [
|
|
29
31
|
endpointGroupsRouter,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
auditLogsRoutes,
|
|
33
|
+
groupRoutes,
|
|
34
|
+
scimRoutes,
|
|
33
35
|
]
|
package/src/sdk/users/users.ts
CHANGED
|
@@ -22,7 +22,7 @@ export async function invite(
|
|
|
22
22
|
|
|
23
23
|
// separate duplicates from new users
|
|
24
24
|
for (let user of users) {
|
|
25
|
-
if (matchedEmails.includes(user.email)) {
|
|
25
|
+
if (matchedEmails.includes(user.email.toLowerCase())) {
|
|
26
26
|
// This "Unavailable" is load bearing. The tests and frontend both check for it
|
|
27
27
|
// specifically
|
|
28
28
|
response.unsuccessful.push({ email: user.email, reason: "Unavailable" })
|