diff --git a/ADR.md b/ADR.md index b1d3fc5..cd33292 100644 --- a/ADR.md +++ b/ADR.md @@ -76,3 +76,22 @@ Consequences: Without the key no secret can be saved or used, and a changed key secrets unusable until they are entered again. SHA-256 does not slow down guessing a weak key, so the key has to be long and random. Anyone who reaches the routes can replace credentials, as the applications have no authentication. + +## ADR-4: The key of a credential store is a path, so related stores share one object + +Status: Accepted + +Context: sql-query-webui keeps connection profiles of PostgreSQL and of SQL Server. As stores of +top-level keys, the keys have to carry the kind in their names, and a key such as `connections` +does not say which kind it holds once there are several. + +Decision: The key of a store is a key of `config.json` or a path of keys separated by dots, such +as `connections.pg` and `connections.mssql`. The store reads the list at the path, reports its +problems by `..`, and a write sets the list at the path, creating the objects +of the path that are missing, or are not objects, and keeping their other keys, so stores of one +object can be written one after the other. The kind stays the store's, not a field of an entry +(ADR-3). + +Consequences: A key of `config.json` holding a dot cannot be the key of a store. A single key +reads and writes as before, so applications of one-key stores need no change. The application +checks the object of a path, such as the keys it allows in it, itself. diff --git a/CHANGELOG.md b/CHANGELOG.md index fa56d0d..7f0b41c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.3.0 + +feature: the key of a credential store may be a path of keys, so stores of several kinds share one object of config.json. + +- `CredentialStore.key` is a key of `config.json` or a path of keys separated by dots, such as + `connections.pg`; read, validate, write and `credentialValues` use the list at that path, and a + write creates the objects of the path that are missing and keeps their other keys. + ## 0.2.1 fix: a credential store holds credentials of one kind, without a kind field in its entries, and the dialog edits several stores in tabs. diff --git a/README.md b/README.md index 9ce6b06..2f783eb 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,19 @@ saved or used; a secret encrypted with another key cannot be decrypted and has t again. `CredentialStore` describes a store: the path of `config.json`, the key, the kind, the reserved -names and whether a missing `config.json` reads as `{}`. +names and whether a missing `config.json` reads as `{}`. The key is a key of `config.json` or a +path of keys separated by dots, such as `connections.pg`, for a list in nested objects; a write +creates the objects of the path that are missing and keeps their other keys, so stores of several +kinds can share one object: + +```json +{ + "connections": { + "pg": [{ "name": "state", "host": "db.example.com", "port": 5432, "...": "..." }], + "mssql": [{ "name": "warehouse", "host": "sql.example.com", "port": 1433, "...": "..." }] + } +} +``` - `credentialProblems(value, store)`: the problems of the key's value by `..`, for the application's own check of `config.json`. diff --git a/package-lock.json b/package-lock.json index 0291af0..f6cfa93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@koszewscy/sk-react-toolkit", - "version": "0.2.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@koszewscy/sk-react-toolkit", - "version": "0.2.1", + "version": "0.3.0", "devDependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", diff --git a/package.json b/package.json index 14d75a5..2162481 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@koszewscy/sk-react-toolkit", - "version": "0.2.1", + "version": "0.3.0", "description": "Shared code of the React and Fastify web applications: web components, server helpers and the Vite font plugin.", "private": true, "type": "module", diff --git a/server/credentials.ts b/server/credentials.ts index b6b09d7..91884f1 100644 --- a/server/credentials.ts +++ b/server/credentials.ts @@ -8,7 +8,8 @@ import { decrypt, encrypt, encryptionKey } from './secrets.ts' /** * 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 + * with the names reserved for its own credentials. key is a key of config.json or a path of keys separated by dots, + * such as connections.pg, for a list in nested objects. With missingAsEmpty a missing config.json reads as {}, and the * first write creates it. */ export type CredentialStore = { @@ -114,9 +115,30 @@ export function credentialProblems(entries: unknown, store: Pick => value !== null && typeof value === 'object' && !Array.isArray(value) + +/** The value of data at key, a key or a path of keys separated by dots; undefined when it is not there. */ +function valueAt(data: Record, key: string): unknown { + return key.split('.').reduce((value, part) => (isObject(value) ? value[part] : undefined), data) +} + +/** + * data with value at key, a key or a path of keys separated by dots, after the other keys of its object; the objects + * of the path that are missing, or are not objects, are created, and their other keys kept. + */ +function withValue(data: Record, key: string, value: unknown): Record { + const [first, ...rest] = key.split('.') + if (rest.length > 0) { + return { ...data, [first]: withValue(isObject(data[first]) ? data[first] : {}, rest.join('.'), value) } + } + const others = { ...data } + delete others[first] + return { ...others, [first]: value } +} + /** The entries of the store's key in config.json; a value that is not a list reads as none. */ function entriesOf(data: Record, store: CredentialStore): Record[] { - const entries = data[store.key] + const entries = valueAt(data, store.key) return Array.isArray(entries) ? entries.filter((entry) => entry !== null && typeof entry === 'object') : [] } @@ -185,7 +207,7 @@ function toEntries(profiles: CredentialProfile[], data: Record, */ export function credentialValues(store: CredentialStore, name: string): CredentialValues { const { data } = read(store) - const [problem] = Object.entries(credentialProblems(data[store.key], store)) + const [problem] = Object.entries(credentialProblems(valueAt(data, store.key), store)) if (problem !== undefined) { throw new HttpError(500, `${problem[0]} in ${store.configPath} ${problem[1]}.`) } @@ -234,8 +256,8 @@ export function registerCredentialRoutes(app: FastifyInstance, store: Credential function problemsOf(value: CredentialProfile[], data: Record): Record { const problems: Record = {} for (const [path, message] of Object.entries(credentialProblems(toEntries(value, data, store, () => 'new'), store))) { - const [, index, field] = path.split('.') - problems[field === undefined ? index ?? store.key : `${index}.${field}`] = `${field ?? 'The credential'} ${message}.` + const [index, field] = path.slice(store.key.length + 1).split('.') + problems[field === undefined ? index || store.key : `${index}.${field}`] = `${field ?? 'The credential'} ${message}.` } if (key() === null) { value.forEach((profile, index) => { @@ -279,11 +301,9 @@ export function registerCredentialRoutes(app: FastifyInstance, store: Credential throw new HttpError(422, 'The credentials are not valid.', problems) } const sealing = key()! - const others = { ...file.data } - delete others[store.key] const entries = toEntries(request.body.value, file.data, store, (secret) => encrypt(secret, sealing)) try { - writeFileSync(store.configPath, `${JSON.stringify({ ...others, [store.key]: entries }, null, 2)}\n`) + writeFileSync(store.configPath, `${JSON.stringify(withValue(file.data, store.key, entries), null, 2)}\n`) } catch (error) { throw new HttpError(500, `${store.configPath} cannot be written: ${(error as Error).message}`) }