@crombolo/server 0.13.2 → 0.14.4
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 +12 -12
- package/src/adapters/bundles.ts +20 -139
- package/src/adapters/members.ts +22 -10
- package/src/adapters/schema-proposals.ts +10 -2
- package/src/adapters/users.ts +23 -1
- package/src/api-client.ts +47 -0
- package/src/backoffice-external.ts +99 -0
- package/src/bundle-install.ts +278 -77
- package/src/bundle-sections.ts +160 -0
- package/src/bundles.ts +4 -0
- package/src/config.ts +123 -2
- package/src/database.ts +21 -8
- package/src/delivery-members.ts +11 -2
- package/src/external-sign-in.ts +270 -0
- package/src/identity-provider-overrides.ts +283 -0
- package/src/identity-providers.ts +185 -0
- package/src/index.ts +12 -0
- package/src/media-store-azure.ts +7 -1
- package/src/media-store-s3.ts +1 -0
- package/src/media-store.ts +25 -2
- package/src/member-auth.ts +232 -15
- package/src/ports.ts +55 -1
- package/src/remote-credentials.ts +108 -0
- package/src/remote-media.ts +91 -0
- package/src/remote.ts +793 -0
- package/src/schema-store.ts +16 -0
- package/src/schema.ts +5 -7
- package/src/server.ts +195 -11
- package/src/site-file.ts +292 -0
- package/src/site-replace.ts +188 -0
- package/src/site-search.ts +51 -0
- package/src/site-sections.ts +234 -0
- package/src/tar.ts +164 -0
- package/src/two-factor.ts +138 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.4",
|
|
4
4
|
"description": "The crombolo HTTP server: routing, configuration, media stores and background jobs.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,17 +28,17 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/api-delivery": "0.
|
|
32
|
-
"@crombolo/api-management": "0.
|
|
33
|
-
"@crombolo/assistant": "0.
|
|
34
|
-
"@crombolo/auth": "0.
|
|
35
|
-
"@crombolo/backoffice-host": "0.
|
|
36
|
-
"@crombolo/contracts": "0.
|
|
37
|
-
"@crombolo/core": "0.
|
|
38
|
-
"@crombolo/data": "0.
|
|
39
|
-
"@crombolo/render": "0.
|
|
40
|
-
"@crombolo/schema": "0.
|
|
41
|
-
"@crombolo/transfer": "0.
|
|
31
|
+
"@crombolo/api-delivery": "0.14.4",
|
|
32
|
+
"@crombolo/api-management": "0.14.4",
|
|
33
|
+
"@crombolo/assistant": "0.14.4",
|
|
34
|
+
"@crombolo/auth": "0.14.4",
|
|
35
|
+
"@crombolo/backoffice-host": "0.14.4",
|
|
36
|
+
"@crombolo/contracts": "0.14.4",
|
|
37
|
+
"@crombolo/core": "0.14.4",
|
|
38
|
+
"@crombolo/data": "0.14.4",
|
|
39
|
+
"@crombolo/render": "0.14.4",
|
|
40
|
+
"@crombolo/schema": "0.14.4",
|
|
41
|
+
"@crombolo/transfer": "0.14.4",
|
|
42
42
|
"@logtape/logtape": "^2.3.8"
|
|
43
43
|
}
|
|
44
44
|
}
|
package/src/adapters/bundles.ts
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
* items as the `.udt` the dictionary already exports. The build is composition,
|
|
9
9
|
* which is why a bundle can carry everything the builder UI offers.
|
|
10
10
|
*/
|
|
11
|
-
import { join } from 'node:path'
|
|
12
11
|
import {
|
|
13
12
|
type BuiltPackage,
|
|
14
13
|
type FileSystemPort,
|
|
@@ -23,31 +22,15 @@ import {
|
|
|
23
22
|
type Bundle,
|
|
24
23
|
BundleRepository,
|
|
25
24
|
type ComponentFileStore,
|
|
26
|
-
ComponentRepository,
|
|
27
|
-
currentSchemaState,
|
|
28
25
|
type Db,
|
|
29
|
-
DictionaryRepository,
|
|
30
26
|
type NodeRow,
|
|
31
27
|
resolveNodeRef,
|
|
32
28
|
} from '@crombolo/data'
|
|
33
29
|
import {
|
|
34
|
-
exportSchemaSet,
|
|
35
|
-
fileNameFor,
|
|
36
|
-
type SchemaSet,
|
|
37
|
-
writeContentType,
|
|
38
|
-
writeDataType,
|
|
39
|
-
writeLanguages,
|
|
40
|
-
writeMediaType,
|
|
41
|
-
writeSchemaVersion,
|
|
42
|
-
} from '@crombolo/schema'
|
|
43
|
-
import {
|
|
44
|
-
BUNDLE_SECTIONS,
|
|
45
30
|
type BundleBlob,
|
|
46
31
|
type BundleDependency,
|
|
47
|
-
type BundleFile,
|
|
48
32
|
type BundleKind,
|
|
49
33
|
type BundleNode,
|
|
50
|
-
type BundleSection,
|
|
51
34
|
bundleIdFor,
|
|
52
35
|
bundlePeerRange,
|
|
53
36
|
type ContentSet,
|
|
@@ -58,9 +41,9 @@ import {
|
|
|
58
41
|
writeBundlePackage,
|
|
59
42
|
} from '@crombolo/transfer'
|
|
60
43
|
import { VERSION } from '../config.ts'
|
|
61
|
-
import { dictionaryToUdt, writeDictionaryUdt } from '../dictionary-transfer.ts'
|
|
62
44
|
import { readBlobs } from '../media-files.ts'
|
|
63
45
|
import type { MediaStore } from '../media-store.ts'
|
|
46
|
+
import { siteSections } from '../site-sections.ts'
|
|
64
47
|
import { writeNpmTarball } from '../tar.ts'
|
|
65
48
|
|
|
66
49
|
/** The npm package a definition is built as: what was set on it, or what its name gives. */
|
|
@@ -119,48 +102,6 @@ const stored = <T extends PackageDefinitionInput>(input: T) => ({
|
|
|
119
102
|
components: [...input.templates, ...input.partialViews],
|
|
120
103
|
})
|
|
121
104
|
|
|
122
|
-
/** Matches a selection against a type's key or its alias, since the UI sends either. */
|
|
123
|
-
const picks = (selected: readonly string[]) => {
|
|
124
|
-
const wanted = new Set(selected.map((value) => value.trim().toLowerCase()))
|
|
125
|
-
return (type: { key?: string; alias: string }): boolean =>
|
|
126
|
-
wanted.has(type.alias.toLowerCase()) ||
|
|
127
|
-
(type.key !== undefined && wanted.has(type.key.toLowerCase()))
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** The selected slice of the site's schema, as the files `schema/` would hold. */
|
|
131
|
-
function schemaEntries(set: SchemaSet, from: PackageDefinition): BundleFile[] {
|
|
132
|
-
const entries: BundleFile[] = []
|
|
133
|
-
const put = (path: string, text: string) =>
|
|
134
|
-
entries.push({ path: `${BUNDLE_SECTIONS.schema}/${path}`, text })
|
|
135
|
-
|
|
136
|
-
const contentTypes = set.contentTypes.filter(picks(from.contentTypes))
|
|
137
|
-
const mediaTypes = (set.mediaTypes ?? []).filter(picks(from.mediaTypes))
|
|
138
|
-
// Member types have no picker in the builder, and the contract's definition
|
|
139
|
-
// has no field for them, so a bundle never carries one.
|
|
140
|
-
const dataTypes = set.dataTypes.filter(picks(from.dataTypes))
|
|
141
|
-
const languages = set.languages.filter((language) =>
|
|
142
|
-
from.languages.some((pick) => pick.toLowerCase() === language.iso.toLowerCase()),
|
|
143
|
-
)
|
|
144
|
-
|
|
145
|
-
if (
|
|
146
|
-
contentTypes.length === 0 &&
|
|
147
|
-
mediaTypes.length === 0 &&
|
|
148
|
-
dataTypes.length === 0 &&
|
|
149
|
-
languages.length === 0
|
|
150
|
-
)
|
|
151
|
-
return entries
|
|
152
|
-
|
|
153
|
-
put('schema.toml', writeSchemaVersion(set.version))
|
|
154
|
-
for (const type of contentTypes)
|
|
155
|
-
put(join('content-types', fileNameFor(type.alias)), writeContentType(type))
|
|
156
|
-
for (const type of mediaTypes)
|
|
157
|
-
put(join('media-types', fileNameFor(type.alias)), writeMediaType(type))
|
|
158
|
-
for (const type of dataTypes)
|
|
159
|
-
put(join('data-types', fileNameFor(type.alias)), writeDataType(type))
|
|
160
|
-
if (languages.length > 0) put('languages.toml', writeLanguages(languages))
|
|
161
|
-
return entries
|
|
162
|
-
}
|
|
163
|
-
|
|
164
105
|
/**
|
|
165
106
|
* Several exports as one bundle: the nodes deduped by key, the counts summed,
|
|
166
107
|
* and the dependencies and selector unioned, so the manifest describes what the
|
|
@@ -227,73 +168,6 @@ export function createPackagePort(db: Db, options: PackagePortOptions): PackageP
|
|
|
227
168
|
const validate = (input: PackageDefinitionInput): PackageWriteResult | undefined =>
|
|
228
169
|
input.name.length === 0 ? { ok: false, status: 'InvalidName' } : undefined
|
|
229
170
|
|
|
230
|
-
/**
|
|
231
|
-
* The files a selection of file-system paths resolves to, under one section.
|
|
232
|
-
*
|
|
233
|
-
* The section is a logical name — `styles`, not `css` — because a site holds
|
|
234
|
-
* these wherever it is configured to, and the importer is what maps a section
|
|
235
|
-
* onto a directory.
|
|
236
|
-
*/
|
|
237
|
-
const fileEntries = async (
|
|
238
|
-
port: FileSystemPort | undefined,
|
|
239
|
-
paths: readonly string[],
|
|
240
|
-
section: BundleSection,
|
|
241
|
-
): Promise<BundleFile[]> => {
|
|
242
|
-
if (!port) return []
|
|
243
|
-
const files: BundleFile[] = []
|
|
244
|
-
for (const path of paths) {
|
|
245
|
-
const file = await port.read(path)
|
|
246
|
-
if (!file) continue
|
|
247
|
-
const relative = file.path.replace(/^\/+/, '')
|
|
248
|
-
files.push({ path: `${BUNDLE_SECTIONS[section]}/${relative}`, text: file.content })
|
|
249
|
-
}
|
|
250
|
-
return files
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const componentEntries = async (keys: readonly string[]): Promise<BundleFile[]> => {
|
|
254
|
-
if (keys.length === 0) return []
|
|
255
|
-
const templates = new ComponentRepository(db, options.componentFiles)
|
|
256
|
-
const all = await templates.all()
|
|
257
|
-
const wanted = new Set(keys.map((key) => key.trim().toLowerCase()))
|
|
258
|
-
const files: BundleFile[] = []
|
|
259
|
-
for (const template of all) {
|
|
260
|
-
if (!wanted.has(template.key.toLowerCase()) && !wanted.has(template.alias.toLowerCase()))
|
|
261
|
-
continue
|
|
262
|
-
files.push({
|
|
263
|
-
path: `${BUNDLE_SECTIONS.components}/${template.alias}.tsx`,
|
|
264
|
-
text: template.content ?? '',
|
|
265
|
-
})
|
|
266
|
-
}
|
|
267
|
-
return files
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* The components a bundle carries, from one list that holds two kinds of
|
|
272
|
-
* name.
|
|
273
|
-
*
|
|
274
|
-
* The contract has `templates` and `partialViews`; this side has one list, so
|
|
275
|
-
* a picked component arrives either as a template alias — a row in
|
|
276
|
-
* `template`, whose file is read through the alias — or as a path in the
|
|
277
|
-
* components tree. Both are tried and the results are keyed by path, so a
|
|
278
|
-
* component picked both ways is carried once.
|
|
279
|
-
*/
|
|
280
|
-
const componentSection = async (picked: readonly string[]): Promise<BundleFile[]> => {
|
|
281
|
-
const byPath = new Map<string, BundleFile>()
|
|
282
|
-
for (const file of await componentEntries(picked)) byPath.set(file.path, file)
|
|
283
|
-
for (const file of await fileEntries(options.components, picked, 'components'))
|
|
284
|
-
byPath.set(file.path, file)
|
|
285
|
-
return [...byPath.values()]
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
const dictionaryEntries = async (keys: readonly string[]): Promise<BundleFile[]> => {
|
|
289
|
-
if (keys.length === 0) return []
|
|
290
|
-
const repo = new DictionaryRepository(db)
|
|
291
|
-
const items = []
|
|
292
|
-
for (const key of keys) items.push(...(await dictionaryToUdt(repo, key)))
|
|
293
|
-
if (items.length === 0) return []
|
|
294
|
-
return [{ path: `${BUNDLE_SECTIONS.dictionary}.udt`, text: writeDictionaryUdt(items) }]
|
|
295
|
-
}
|
|
296
|
-
|
|
297
171
|
/**
|
|
298
172
|
* The content, media and element picks as one transfer bundle, blobs included.
|
|
299
173
|
*
|
|
@@ -404,18 +278,25 @@ export function createPackagePort(db: Db, options: PackagePortOptions): PackageP
|
|
|
404
278
|
const from = definition(row)
|
|
405
279
|
const pkg = bundlePackageOf(row)
|
|
406
280
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
281
|
+
const sections = await siteSections(
|
|
282
|
+
{
|
|
283
|
+
db,
|
|
284
|
+
componentFiles: options.componentFiles,
|
|
285
|
+
components: options.components,
|
|
286
|
+
stylesheets: options.stylesheets,
|
|
287
|
+
scripts: options.scripts,
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
contentTypes: from.contentTypes,
|
|
291
|
+
mediaTypes: from.mediaTypes,
|
|
292
|
+
dataTypes: from.dataTypes,
|
|
293
|
+
languages: from.languages,
|
|
294
|
+
components: [...from.templates, ...from.partialViews],
|
|
295
|
+
stylesheets: from.stylesheets,
|
|
296
|
+
scripts: from.scripts,
|
|
297
|
+
dictionaryItems: from.dictionaryItems,
|
|
298
|
+
},
|
|
299
|
+
)
|
|
419
300
|
|
|
420
301
|
const content = await contentEntries(from)
|
|
421
302
|
// A package with nothing picked is still a bundle: an empty selection
|
package/src/adapters/members.ts
CHANGED
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
* - **Passwords.** Plaintext arrives here and nowhere deeper; a change of one
|
|
13
13
|
* rotates the security stamp, which ends the member's sessions.
|
|
14
14
|
*
|
|
15
|
-
* `kind` is
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* `kind` is `Api` for a Delivery API client-credentials member, `ExternalOnly`
|
|
16
|
+
* for one with no password, who signs in only through an identity provider,
|
|
17
|
+
* and `Default` otherwise.
|
|
18
18
|
*/
|
|
19
19
|
import type {
|
|
20
20
|
MemberFilterCriteria,
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
type DocumentRepositoryOptions,
|
|
38
38
|
type MemberCredentials,
|
|
39
39
|
MemberRepository,
|
|
40
|
+
TwoFactorRepository,
|
|
40
41
|
type ValueIntake,
|
|
41
42
|
WriteRejectedError,
|
|
42
43
|
} from '@crombolo/data'
|
|
@@ -60,10 +61,18 @@ const notFound = () => ({
|
|
|
60
61
|
|
|
61
62
|
export function createMemberPort(db: Db, options: MemberPortOptions = {}): MemberPort {
|
|
62
63
|
const apiMembers = new Set((options.apiMemberUserNames ?? []).map((n) => n.toLowerCase()))
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
// A member with no password of their own signs in only through an identity
|
|
65
|
+
// provider (story 031): Umbraco's `ExternalOnly`, which the workspace shows
|
|
66
|
+
// with the username read-only and the password optional.
|
|
67
|
+
const kindOf = (member: MemberCredentials): 'Api' | 'ExternalOnly' | 'Default' =>
|
|
68
|
+
apiMembers.has(member.username.toLowerCase())
|
|
69
|
+
? 'Api'
|
|
70
|
+
: member.passwordHash
|
|
71
|
+
? 'Default'
|
|
72
|
+
: 'ExternalOnly'
|
|
65
73
|
const members = new MemberRepository(db, options)
|
|
66
74
|
const memberTypes = new ContentTypeRepository(db, { kind: 'member' })
|
|
75
|
+
const twoFactor = new TwoFactorRepository(db)
|
|
67
76
|
|
|
68
77
|
const userIdOf = async (principal: Principal): Promise<number | undefined> => {
|
|
69
78
|
const rows = await db.query<{ id: number }>('SELECT id FROM user_account WHERE key = ?', [
|
|
@@ -111,14 +120,13 @@ export function createMemberPort(db: Db, options: MemberPortOptions = {}): Membe
|
|
|
111
120
|
},
|
|
112
121
|
isApproved: member.isApproved,
|
|
113
122
|
isLockedOut: member.isLockedOut,
|
|
114
|
-
|
|
115
|
-
isTwoFactorEnabled: false,
|
|
123
|
+
isTwoFactorEnabled: (await twoFactor.providers(member.key)).length > 0,
|
|
116
124
|
failedPasswordAttempts: member.failedPasswordAttempts,
|
|
117
125
|
lastLoginDate: member.lastLoginDate?.toISOString() ?? null,
|
|
118
126
|
lastLockoutDate: member.lastLockoutDate?.toISOString() ?? null,
|
|
119
127
|
lastPasswordChangeDate: member.lastPasswordChangeDate?.toISOString() ?? null,
|
|
120
128
|
groups: await members.groupKeys(member.key),
|
|
121
|
-
kind: kindOf(member
|
|
129
|
+
kind: kindOf(member),
|
|
122
130
|
profileData: null,
|
|
123
131
|
flags: [],
|
|
124
132
|
values: content.values
|
|
@@ -144,7 +152,7 @@ export function createMemberPort(db: Db, options: MemberPortOptions = {}): Membe
|
|
|
144
152
|
id: member.key,
|
|
145
153
|
memberType: { id: member.contentTypeKey, icon: member.contentTypeIcon ?? 'icon-user' },
|
|
146
154
|
variants: [{ name: member.name, culture: null }],
|
|
147
|
-
kind: kindOf(member
|
|
155
|
+
kind: kindOf(member),
|
|
148
156
|
flags: [],
|
|
149
157
|
})
|
|
150
158
|
|
|
@@ -239,7 +247,11 @@ export function createMemberPort(db: Db, options: MemberPortOptions = {}): Membe
|
|
|
239
247
|
groupKeys: input.groupKeys,
|
|
240
248
|
},
|
|
241
249
|
)
|
|
242
|
-
|
|
250
|
+
if (!updated) return notFound()
|
|
251
|
+
// The workspace's two-factor toggle can only be turned off: an
|
|
252
|
+
// administrator's reset for a member who lost their phone (story 032).
|
|
253
|
+
if (!input.isTwoFactorEnabled) await twoFactor.disable(key)
|
|
254
|
+
return { ok: true as const }
|
|
243
255
|
} catch (error) {
|
|
244
256
|
return failure(error)
|
|
245
257
|
}
|
|
@@ -36,6 +36,7 @@ import type { CromboloConfig } from '../config.ts'
|
|
|
36
36
|
import { formTypesFor } from '../form-types.ts'
|
|
37
37
|
import { logger } from '../logging.ts'
|
|
38
38
|
import { componentAliasesIn, importSchema } from '../schema.ts'
|
|
39
|
+
import { publishSchema, useSchemaStore } from '../schema-store.ts'
|
|
39
40
|
|
|
40
41
|
const log = logger('schema')
|
|
41
42
|
|
|
@@ -124,6 +125,9 @@ export function createSchemaProposals(options: SchemaProposalOptions): SchemaPro
|
|
|
124
125
|
return { ok: false, error: problems.map((problem) => problem.message).join('; ') }
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
// Fresh from the store first, then written, imported and published without
|
|
129
|
+
// materialising again, which would lay the store's copy over this write.
|
|
130
|
+
await useSchemaStore(config)
|
|
127
131
|
const path = pathFor(kind, alias)
|
|
128
132
|
const versionFile = join(config.schemaDir, 'schema.toml')
|
|
129
133
|
const previous = existsSync(path) ? await readFile(path, 'utf8') : undefined
|
|
@@ -143,14 +147,17 @@ export function createSchemaProposals(options: SchemaProposalOptions): SchemaPro
|
|
|
143
147
|
// is nothing left to compare. The version moves in the same breath, or the
|
|
144
148
|
// import lands at a version the other nodes already have and they never
|
|
145
149
|
// learn they are behind.
|
|
146
|
-
const ahead = await importSchema(options.db, config, options.boot, {
|
|
150
|
+
const ahead = await importSchema(options.db, config, options.boot, {
|
|
151
|
+
dryRun: true,
|
|
152
|
+
materialise: false,
|
|
153
|
+
})
|
|
147
154
|
const { version, urls } = loadSchemaDirectory(config.schemaDir, {
|
|
148
155
|
formTypes: formTypesFor(config),
|
|
149
156
|
}).set
|
|
150
157
|
const next = nextSchemaVersion(version, ahead.classification)
|
|
151
158
|
if (next !== version) await writeFile(versionFile, writeSchemaVersion(next, urls), 'utf8')
|
|
152
159
|
|
|
153
|
-
const result = await importSchema(options.db, config, options.boot)
|
|
160
|
+
const result = await importSchema(options.db, config, options.boot, { materialise: false })
|
|
154
161
|
if (result.report && result.report.action !== 'applied') {
|
|
155
162
|
// Put the files back: a refused import leaves the site with a file it will
|
|
156
163
|
// not accept, which is worse than the change not having happened.
|
|
@@ -160,6 +167,7 @@ export function createSchemaProposals(options: SchemaProposalOptions): SchemaPro
|
|
|
160
167
|
error: result.report.reason ?? `the import was ${result.report.action}`,
|
|
161
168
|
}
|
|
162
169
|
}
|
|
170
|
+
if (config.schemaStore) await publishSchema(config.schemaStore, config.schemaDir)
|
|
163
171
|
await options.afterSync?.(result.report)
|
|
164
172
|
log.info('{alias} applied from a proposal: {classification}, schema at {version}', {
|
|
165
173
|
alias,
|
package/src/adapters/users.ts
CHANGED
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
import {
|
|
42
42
|
type Db,
|
|
43
43
|
DbDate,
|
|
44
|
+
ExternalLoginRepository,
|
|
45
|
+
externalLoginProvider,
|
|
44
46
|
type GranularPermission,
|
|
45
47
|
SUPER_USER_KEY,
|
|
46
48
|
UserDataRepository,
|
|
@@ -106,7 +108,12 @@ type Tree = 'document' | 'media' | 'element'
|
|
|
106
108
|
export interface UserPortOptions {
|
|
107
109
|
config: Pick<
|
|
108
110
|
CromboloConfig,
|
|
109
|
-
|
|
111
|
+
| 'usernameIsEmail'
|
|
112
|
+
| 'applicationUrl'
|
|
113
|
+
| 'sendUserLink'
|
|
114
|
+
| 'allowPasswordReset'
|
|
115
|
+
| 'backOfficePath'
|
|
116
|
+
| 'backofficeIdentityProviders'
|
|
110
117
|
>
|
|
111
118
|
mediaFiles: MediaFileStore
|
|
112
119
|
nodes: NodeLookup
|
|
@@ -276,6 +283,21 @@ export function createUserPorts(
|
|
|
276
283
|
}),
|
|
277
284
|
passwordConfiguration: () => DEFAULT_PASSWORD_CONFIGURATION,
|
|
278
285
|
|
|
286
|
+
// Linking happens by signing in through the provider; the profile only lists it.
|
|
287
|
+
async loginProviders(principal) {
|
|
288
|
+
const linked = await new ExternalLoginRepository(db).forOwner(principal.id)
|
|
289
|
+
return config.backofficeIdentityProviders.map((provider) => {
|
|
290
|
+
const scheme = externalLoginProvider('user', provider.oidc.id)
|
|
291
|
+
const link = linked.find((l) => l.loginProvider.toLowerCase() === scheme.toLowerCase())
|
|
292
|
+
return {
|
|
293
|
+
providerSchemeName: scheme,
|
|
294
|
+
providerKey: link?.providerKey ?? null,
|
|
295
|
+
isLinkedOnUser: Boolean(link),
|
|
296
|
+
hasManualLinkingEnabled: false,
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
},
|
|
300
|
+
|
|
279
301
|
async list(principal, skip, take) {
|
|
280
302
|
const admin = await adminKey()
|
|
281
303
|
const visible = (await users.all()).filter(await visibleTo(principal))
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The API client an unattended install seeds (story 034): an API user in the
|
|
3
|
+
* administrators group, holding the configured client, so a pipeline can sign
|
|
4
|
+
* in with the client-credentials grant on a site whose backoffice only takes an
|
|
5
|
+
* identity provider. Created once; a client that exists is never rotated from
|
|
6
|
+
* config. `docs/operations.md`.
|
|
7
|
+
*/
|
|
8
|
+
import { hashPassword } from '@crombolo/auth'
|
|
9
|
+
import { ADMIN_GROUP_ALIAS } from '@crombolo/core'
|
|
10
|
+
import { BUILT_IN_GROUPS, closeSetup, type Db, UserRepository } from '@crombolo/data'
|
|
11
|
+
import { recordAudit } from './adapters/audit.ts'
|
|
12
|
+
|
|
13
|
+
/** Seeds the client when no user holds its id; `existed` when one already does. */
|
|
14
|
+
export async function ensureApiClient(
|
|
15
|
+
db: Db,
|
|
16
|
+
client: { id: string; secret: string },
|
|
17
|
+
): Promise<'created' | 'existed'> {
|
|
18
|
+
const users = new UserRepository(db)
|
|
19
|
+
if (await users.clientCredentialOwner(client.id)) {
|
|
20
|
+
// A machine can already administer the site, so the browser setup step goes.
|
|
21
|
+
await closeSetup(db)
|
|
22
|
+
return 'existed'
|
|
23
|
+
}
|
|
24
|
+
const admins = BUILT_IN_GROUPS.find((group) => group.alias === ADMIN_GROUP_ALIAS)
|
|
25
|
+
const created = await users.create({
|
|
26
|
+
key: crypto.randomUUID(),
|
|
27
|
+
name: 'API client',
|
|
28
|
+
userName: client.id,
|
|
29
|
+
email: '',
|
|
30
|
+
kind: 'Api',
|
|
31
|
+
groupKeys: admins ? [admins.key] : [],
|
|
32
|
+
languageIsoCode: null,
|
|
33
|
+
})
|
|
34
|
+
if (!created) throw new Error(`The API client ${client.id} could not be created.`)
|
|
35
|
+
await users.addClientCredential(created.id, client.id, await hashPassword(client.secret))
|
|
36
|
+
await closeSetup(db)
|
|
37
|
+
await recordAudit(db, {
|
|
38
|
+
category: 'user',
|
|
39
|
+
action: 'Create',
|
|
40
|
+
source: 'cli',
|
|
41
|
+
entityType: 'User',
|
|
42
|
+
entityKey: created.key,
|
|
43
|
+
entityName: client.id,
|
|
44
|
+
detail: 'API client seeded from CROMBOLO_API_CLIENT_ID',
|
|
45
|
+
})
|
|
46
|
+
return 'created'
|
|
47
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backoffice users signing in through an identity provider (story 031): the
|
|
3
|
+
* user side of `external-sign-in.ts`. A sign-in ends in the same session a
|
|
4
|
+
* password sign-in does, so the authorize endpoint carries on from there.
|
|
5
|
+
*/
|
|
6
|
+
import type { AuthService } from '@crombolo/auth'
|
|
7
|
+
import { type Db, UserGroupRepository, type UserRecord, UserRepository } from '@crombolo/data'
|
|
8
|
+
import type { ExternalAccount, ExternalSignInAudience } from './external-sign-in.ts'
|
|
9
|
+
import type { IdentityProvider } from './identity-providers.ts'
|
|
10
|
+
|
|
11
|
+
/** Where backoffice sign-ins through a provider answer, under the security path the login uses. */
|
|
12
|
+
export const BACKOFFICE_EXTERNAL_PATH = '/umbraco/management/api/v1/security/back-office/external'
|
|
13
|
+
|
|
14
|
+
export function backofficeAudience(
|
|
15
|
+
db: Db,
|
|
16
|
+
options: {
|
|
17
|
+
auth: AuthService
|
|
18
|
+
providers: readonly IdentityProvider[]
|
|
19
|
+
backOfficePath: string
|
|
20
|
+
/** The `Set-Cookie` value for a new backoffice session. */
|
|
21
|
+
sessionCookie(sessionId: string): string
|
|
22
|
+
clientAddress?(request: Request): string | undefined
|
|
23
|
+
/** Called once a user is signed in, for the audit log. */
|
|
24
|
+
onSignIn?(user: UserRecord, request: Request): Promise<void>
|
|
25
|
+
},
|
|
26
|
+
): ExternalSignInAudience {
|
|
27
|
+
const users = new UserRepository(db)
|
|
28
|
+
const groups = new UserGroupRepository(db)
|
|
29
|
+
|
|
30
|
+
const aliasesOf = async (user: UserRecord) => {
|
|
31
|
+
const found = await groups.byKeys(user.groupKeys)
|
|
32
|
+
return found.map((g) => g.alias)
|
|
33
|
+
}
|
|
34
|
+
const keysOf = async (aliases: readonly string[]) => {
|
|
35
|
+
const all = await groups.all()
|
|
36
|
+
return aliases.flatMap((alias) => {
|
|
37
|
+
const group = all.find((g) => g.alias.toLowerCase() === alias.toLowerCase())
|
|
38
|
+
return group ? [group.key] : []
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
const asAccount = async (user: UserRecord): Promise<ExternalAccount> => ({
|
|
42
|
+
key: user.key,
|
|
43
|
+
email: user.email,
|
|
44
|
+
groups: await aliasesOf(user),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
audience: 'user',
|
|
49
|
+
providers: options.providers,
|
|
50
|
+
basePath: BACKOFFICE_EXTERNAL_PATH,
|
|
51
|
+
byKey: async (key) => {
|
|
52
|
+
const user = await users.byKey(key)
|
|
53
|
+
return user ? asAccount(user) : undefined
|
|
54
|
+
},
|
|
55
|
+
byEmail: async (email) => {
|
|
56
|
+
const user = await users.byEmail(email)
|
|
57
|
+
return user ? asAccount(user) : undefined
|
|
58
|
+
},
|
|
59
|
+
create: async (identity, provider, groupAliases) => {
|
|
60
|
+
const email = identity.email as string
|
|
61
|
+
const userName = (await users.byUserName(email))
|
|
62
|
+
? `${provider.oidc.id}-${identity.subject}`
|
|
63
|
+
: email
|
|
64
|
+
const created = await users.create({
|
|
65
|
+
key: crypto.randomUUID(),
|
|
66
|
+
name: identity.name || email,
|
|
67
|
+
userName,
|
|
68
|
+
email,
|
|
69
|
+
kind: 'Default',
|
|
70
|
+
groupKeys: await keysOf(groupAliases),
|
|
71
|
+
languageIsoCode: null,
|
|
72
|
+
})
|
|
73
|
+
if (!created) throw new Error(`A user for ${email} could not be created.`)
|
|
74
|
+
return asAccount(created)
|
|
75
|
+
},
|
|
76
|
+
setGroups: async (account, groupAliases) => {
|
|
77
|
+
await users.setGroups([account.key], await keysOf(groupAliases))
|
|
78
|
+
},
|
|
79
|
+
signIn: async (account, request) => {
|
|
80
|
+
const user = await users.byKey(account.key)
|
|
81
|
+
if (!user) return { refused: 'This account no longer exists.' }
|
|
82
|
+
const result = await options.auth.signInExternally(
|
|
83
|
+
user.userName,
|
|
84
|
+
options.clientAddress?.(request),
|
|
85
|
+
)
|
|
86
|
+
if (result.status !== 'success')
|
|
87
|
+
return {
|
|
88
|
+
refused:
|
|
89
|
+
result.reason === 'lockedOut'
|
|
90
|
+
? 'This account is locked out.'
|
|
91
|
+
: 'This account is not allowed to sign in yet.',
|
|
92
|
+
}
|
|
93
|
+
await options.onSignIn?.(user, request)
|
|
94
|
+
return { cookies: [options.sessionCookie(result.sessionId)] }
|
|
95
|
+
},
|
|
96
|
+
failed: (returnUrl, reason) =>
|
|
97
|
+
`${options.backOfficePath}/login?returnUrl=${encodeURIComponent(returnUrl)}&externalLoginError=${encodeURIComponent(reason)}`,
|
|
98
|
+
}
|
|
99
|
+
}
|