fix: a credential store holds credentials of one kind, without a kind field in its entries, and the dialog edits several stores in tabs.
Release / release (push) Successful in 19s
Release / release (push) Successful in 19s
This commit is contained in:
1 parent
b0b7a4bb7f
commit
7959c90012
9 files changed
+319
-224
No files matched your search
+27
-39
@@ -7,27 +7,26 @@ import { readJsonObject } from './process.ts'
|
||||
import { decrypt, encrypt, encryptionKey } from './secrets.ts'
|
||||
|
||||
/**
|
||||
* A credential store: the list under key of config.json at configPath, of the kinds the application gives, with the
|
||||
* names reserved for its own credentials. With missingAsEmpty a missing config.json reads as {}, and the first write
|
||||
* creates it.
|
||||
* A credential store: the list under key of config.json at configPath, every entry of the kind the application gives,
|
||||
* with the names reserved for its own credentials. With missingAsEmpty a missing config.json reads as {}, and the
|
||||
* first write creates it.
|
||||
*/
|
||||
export type CredentialStore = {
|
||||
configPath: string
|
||||
key: string
|
||||
kinds: CredentialKind[]
|
||||
kind: CredentialKind
|
||||
reserved?: string[]
|
||||
missingAsEmpty: boolean
|
||||
}
|
||||
|
||||
/** A credential with its secrets decrypted; a secret that is not kept is absent. */
|
||||
export type CredentialValues = { name: string; kind: string; values: Record<string, string | number> }
|
||||
export type CredentialValues = { name: string; values: Record<string, string | number> }
|
||||
|
||||
/**
|
||||
* The kind of a PostgreSQL connection: host, port, database, user, SSL mode and an encrypted password. The SSL modes
|
||||
* are those libpq and node-postgres both take: disable, require (no check of the certificate) and verify-full.
|
||||
*/
|
||||
export const POSTGRESQL_KIND: CredentialKind = {
|
||||
kind: 'postgresql',
|
||||
label: 'PostgreSQL connection',
|
||||
fields: [
|
||||
{ key: 'host', label: 'Host', help: 'The host name or address of the server.', type: 'text', required: true, secret: false },
|
||||
@@ -55,7 +54,7 @@ const text = (value: unknown) => typeof value === 'string' && value.trim() !== '
|
||||
* The problems of the value of the store's key in config.json, by <key>, <key>.<index> or <key>.<index>.<field key>,
|
||||
* each with what it must be; empty when every value is allowed.
|
||||
*/
|
||||
export function credentialProblems(entries: unknown, store: Pick<CredentialStore, 'key' | 'kinds' | 'reserved'>): Record<string, string> {
|
||||
export function credentialProblems(entries: unknown, store: Pick<CredentialStore, 'key' | 'kind' | 'reserved'>): Record<string, string> {
|
||||
if (entries === undefined) {
|
||||
return {}
|
||||
}
|
||||
@@ -64,10 +63,11 @@ export function credentialProblems(entries: unknown, store: Pick<CredentialStore
|
||||
}
|
||||
const problems: Record<string, string> = {}
|
||||
const reserved = store.reserved ?? []
|
||||
const keys = ['name', ...store.kind.fields.map((field) => field.key)]
|
||||
entries.forEach((entry: unknown, index) => {
|
||||
const at = `${store.key}.${index}`
|
||||
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
problems[at] = 'must be an object with name and kind'
|
||||
problems[at] = `must be an object with ${keys.join(', ')}`
|
||||
return
|
||||
}
|
||||
const record = entry as Record<string, unknown>
|
||||
@@ -79,17 +79,11 @@ export function credentialProblems(entries: unknown, store: Pick<CredentialStore
|
||||
} else if (entries.findIndex((other) => (other as Record<string, unknown> | null)?.name === name) !== index) {
|
||||
problems[`${at}.name`] = 'must differ from the names of the other credentials'
|
||||
}
|
||||
const kind = store.kinds.find((item) => item.kind === record.kind)
|
||||
if (kind === undefined) {
|
||||
problems[`${at}.kind`] = `must be one of ${store.kinds.map((item) => item.kind).join(', ')}`
|
||||
return
|
||||
}
|
||||
const keys = ['name', 'kind', ...kind.fields.map((field) => field.key)]
|
||||
const unknown = Object.keys(record).filter((key) => !keys.includes(key))
|
||||
if (unknown.length > 0) {
|
||||
problems[at] = `must hold only ${keys.join(', ')}; ${unknown.join(', ')} is not a field of ${kind.kind}`
|
||||
problems[at] = `must hold only ${keys.join(', ')}; ${unknown.join(', ')} is not one of them`
|
||||
}
|
||||
for (const field of kind.fields) {
|
||||
for (const field of store.kind.fields) {
|
||||
const value = record[field.key]
|
||||
const path = `${at}.${field.key}`
|
||||
if (value === undefined) {
|
||||
@@ -146,19 +140,15 @@ function read(store: CredentialStore) {
|
||||
|
||||
/** The credentials of the entries, without their secrets. */
|
||||
function toProfiles(data: Record<string, unknown>, store: CredentialStore): CredentialProfile[] {
|
||||
return entriesOf(data, store).map((entry) => {
|
||||
const kind = store.kinds.find((item) => item.kind === entry.kind)
|
||||
const fields = kind?.fields ?? []
|
||||
const shown = (value: unknown) => (typeof value === 'string' || typeof value === 'number' ? String(value) : '')
|
||||
return {
|
||||
original: typeof entry.name === 'string' ? entry.name : null,
|
||||
name: typeof entry.name === 'string' ? entry.name : '',
|
||||
kind: typeof entry.kind === 'string' ? entry.kind : '',
|
||||
values: Object.fromEntries(fields.filter((field) => !field.secret).map((field) => [field.key, shown(entry[field.key])])),
|
||||
secrets: Object.fromEntries(fields.filter((field) => field.secret).map((field) => [field.key, null])),
|
||||
stored: Object.fromEntries(fields.filter((field) => field.secret).map((field) => [field.key, typeof entry[field.key] === 'string'])),
|
||||
}
|
||||
})
|
||||
const { fields } = store.kind
|
||||
const shown = (value: unknown) => (typeof value === 'string' || typeof value === 'number' ? String(value) : '')
|
||||
return entriesOf(data, store).map((entry) => ({
|
||||
original: typeof entry.name === 'string' ? entry.name : null,
|
||||
name: typeof entry.name === 'string' ? entry.name : '',
|
||||
values: Object.fromEntries(fields.filter((field) => !field.secret).map((field) => [field.key, shown(entry[field.key])])),
|
||||
secrets: Object.fromEntries(fields.filter((field) => field.secret).map((field) => [field.key, null])),
|
||||
stored: Object.fromEntries(fields.filter((field) => field.secret).map((field) => [field.key, typeof entry[field.key] === 'string'])),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,13 +159,12 @@ function toProfiles(data: Record<string, unknown>, store: CredentialStore): Cred
|
||||
function toEntries(profiles: CredentialProfile[], data: Record<string, unknown>, store: CredentialStore, seal: (secret: string) => string) {
|
||||
const stored = entriesOf(data, store)
|
||||
return profiles.map((profile) => {
|
||||
const kind = store.kinds.find((item) => item.kind === profile.kind)
|
||||
const previous = profile.original === null ? undefined : stored.find((entry) => entry.name === profile.original)
|
||||
const entry: Record<string, unknown> = { name: profile.name.trim(), kind: profile.kind }
|
||||
for (const field of kind?.fields ?? []) {
|
||||
const entry: Record<string, unknown> = { name: profile.name.trim() }
|
||||
for (const field of store.kind.fields) {
|
||||
if (field.secret) {
|
||||
const secret = profile.secrets[field.key] ?? null
|
||||
const value = secret === null ? (previous?.kind === profile.kind ? previous?.[field.key] : undefined) : secret === '' ? undefined : seal(secret)
|
||||
const value = secret === null ? previous?.[field.key] : secret === '' ? undefined : seal(secret)
|
||||
if (value !== undefined) {
|
||||
entry[field.key] = value
|
||||
}
|
||||
@@ -202,11 +191,10 @@ export function credentialValues(store: CredentialStore, name: string): Credenti
|
||||
}
|
||||
const entry = entriesOf(data, store).find((item) => item.name === name)
|
||||
if (entry === undefined) {
|
||||
throw new HttpError(404, `The credential ${name} does not exist.`)
|
||||
throw new HttpError(404, `The ${store.kind.label} ${name} does not exist.`)
|
||||
}
|
||||
const kind = store.kinds.find((item) => item.kind === entry.kind)!
|
||||
const values: Record<string, string | number> = {}
|
||||
for (const field of kind.fields) {
|
||||
for (const field of store.kind.fields) {
|
||||
const value = entry[field.key]
|
||||
if (value === undefined) {
|
||||
continue
|
||||
@@ -225,7 +213,7 @@ export function credentialValues(store: CredentialStore, name: string): Credenti
|
||||
throw new HttpError(400, `The ${field.label.toLowerCase()} of ${name} cannot be decrypted with the server's encryption key; enter it again.`)
|
||||
}
|
||||
}
|
||||
return { name, kind: kind.kind, values }
|
||||
return { name, values }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -239,7 +227,7 @@ export function registerCredentialRoutes(app: FastifyInstance, store: Credential
|
||||
const flags = { type: 'object', additionalProperties: { type: 'boolean' } }
|
||||
const profiles = {
|
||||
type: 'array',
|
||||
items: strict({ original: { type: ['string', 'null'] }, name: { type: 'string' }, kind: { type: 'string' }, values: record, secrets, stored: flags }),
|
||||
items: strict({ original: { type: ['string', 'null'] }, name: { type: 'string' }, values: record, secrets, stored: flags }),
|
||||
}
|
||||
|
||||
/** The problems of the credentials by <index>.<field key>, a new secret included when the server has no key. */
|
||||
@@ -267,7 +255,7 @@ export function registerCredentialRoutes(app: FastifyInstance, store: Credential
|
||||
value: toProfiles(file.data, store),
|
||||
path: store.configPath,
|
||||
encryption: key() !== null,
|
||||
kinds: store.kinds,
|
||||
kind: store.kind,
|
||||
reserved: store.reserved ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user