@better-forge/config-as-code 0.0.0-stage → 0.1.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/src/routes.ts ADDED
@@ -0,0 +1,143 @@
1
+ import type { FastifyInstance, FastifyReply, FastifyRequest, onRequestAsyncHookHandler } from 'fastify'
2
+ import { tokenAllows } from '@better-forge/contracts'
3
+ import { clip, ConfigExportError, ConfigInvalidError, type Json } from './document.js'
4
+ import type { ConfigEngine } from './engine.js'
5
+ import { readDocuments, toYaml, toYamlStream } from './yaml.js'
6
+
7
+ /** A configuration is a few hundred kilobytes at most; this leaves room without letting a request tie up the parser. */
8
+ const BODY_LIMIT = 2 * 1024 * 1024
9
+ const YAML_TYPES = ['application/yaml', 'application/x-yaml', 'text/yaml']
10
+
11
+ /** Who may export and import one kind of document. */
12
+ export interface KindAccess {
13
+ /** The API token permission prefix: `<resource>:read` to export, `<resource>:write` to import. */
14
+ tokenResource: string
15
+ /** The roles of a signed-in user who may export and import it. */
16
+ roles: readonly string[]
17
+ /** The role named in the message when a user may not. */
18
+ roleLabel: string
19
+ /** Left out of a full export for a reader who may not see it, instead of refusing the whole export. */
20
+ optional?: boolean
21
+ }
22
+
23
+ /** The caller of a request, as far as the routes need to know. */
24
+ export interface ConfigCaller {
25
+ role: string
26
+ apiToken?: { scopes: readonly string[] } | undefined
27
+ }
28
+
29
+ export interface ConfigRoutesOptions<Actor> {
30
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
+ engine: ConfigEngine<any, any, Actor>
32
+ access: Readonly<Record<string, KindAccess>>
33
+ identity: (req: FastifyRequest) => ConfigCaller
34
+ /** What the caller is recorded as in the audit log. */
35
+ actor: (req: FastifyRequest) => Actor
36
+ /** Hooks that run before a file is read (the application's own role check). */
37
+ guards: readonly onRequestAsyncHookHandler[]
38
+ /** Kinds a file needs permission for beyond the kinds of its documents (for example the kind a document links to). */
39
+ linkedKinds?: (documents: unknown[]) => readonly string[]
40
+ }
41
+
42
+ /** The routes to export the configuration as YAML and to validate or apply a YAML file. A plugin to register with `app.register`. */
43
+ export function createConfigRoutes<Actor>(options: ConfigRoutesOptions<Actor>) {
44
+ const { engine, access, identity } = options
45
+ const kindNames = engine.kinds
46
+ const tokenResources = kindNames.map((kind) => access[kind]!.tokenResource)
47
+
48
+ /** The kinds `caller` may not read (or write), with the reason, or null when it may do all of them. */
49
+ function refusal(caller: ConfigCaller, kinds: Iterable<string>, mode: 'read' | 'write'): string | null {
50
+ const refused: string[] = []
51
+ for (const kind of kinds) {
52
+ const rule = access[kind]!
53
+ if (caller.apiToken) {
54
+ const needed = `${rule.tokenResource}:${mode}`
55
+ if (!tokenAllows(caller.apiToken.scopes, needed)) refused.push(`${kind} (the token needs "${needed}")`)
56
+ } else if (!rule.roles.includes(caller.role)) {
57
+ refused.push(`${kind} (needs the ${rule.roleLabel} role)`)
58
+ }
59
+ }
60
+ return refused.length ? `You may not ${mode === 'read' ? 'export' : 'import'}: ${refused.join(', ')}` : null
61
+ }
62
+
63
+ /** The kinds a file needs permission for: its own, and those its documents link to. */
64
+ function kindsNeeded(documents: unknown[]): Set<string> {
65
+ const kinds = engine.kindsIn(documents)
66
+ for (const linked of options.linkedKinds?.(documents) ?? []) kinds.add(linked)
67
+ return kinds
68
+ }
69
+
70
+ const optionalKinds = kindNames.filter((kind) => access[kind]!.optional)
71
+
72
+ return async function configRoutes(app: FastifyInstance) {
73
+ // A YAML body is handed to the handler as text and parsed there, after the request has been authenticated.
74
+ app.addContentTypeParser(YAML_TYPES, { parseAs: 'string', bodyLimit: BODY_LIMIT }, (_req, body, done) => done(null, body))
75
+
76
+ // One document (`?kind=Note&key=first`), or all of them, as YAML.
77
+ app.get<{ Querystring: { kind?: string; key?: string } }>('/export', async (req, reply) => {
78
+ const { kind, key } = req.query
79
+ if (kind !== undefined && !kindNames.includes(kind)) {
80
+ return reply.code(400).send({ error: `kind must be one of: ${kindNames.join(', ')}` })
81
+ }
82
+ if (kind !== undefined && !key) return reply.code(400).send({ error: `key is required to export a ${kind}` })
83
+
84
+ const caller = identity(req)
85
+ const denied = refusal(caller, kind ? [kind] : kindNames.filter((each) => !optionalKinds.includes(each)), 'read')
86
+ if (denied) return reply.code(403).send({ error: denied })
87
+ // Without a key a full export holds the optional kinds only for who may read them.
88
+ const included = new Set(kindNames.filter((each) => !optionalKinds.includes(each) || refusal(caller, [each], 'read') === null))
89
+
90
+ let documents: Json[]
91
+ try {
92
+ documents = await engine.exportDocuments(kind ? { kind, key: key! } : undefined, { included })
93
+ } catch (error) {
94
+ if (error instanceof ConfigExportError) return reply.code(409).send({ error: error.message })
95
+ throw error
96
+ }
97
+ if (kind) {
98
+ const found = engine.pickDocument(documents, kind, key)
99
+ if (!found) return reply.code(404).send({ error: `There is no ${kind} with the key "${clip(String(key))}"` })
100
+ return reply.type('application/yaml; charset=utf-8').send(toYaml(found))
101
+ }
102
+ return reply.type('application/yaml; charset=utf-8').send(toYamlStream(documents))
103
+ })
104
+
105
+ async function run(req: FastifyRequest<{ Body: unknown; Querystring: { prune?: string } }>, reply: FastifyReply, dryRun: boolean) {
106
+ const { prune } = req.query
107
+ if (prune !== undefined && prune !== 'true' && prune !== 'false') return reply.code(400).send({ error: 'prune must be true or false' })
108
+ const read = readDocuments(req.body)
109
+ if ('error' in read) return reply.code(400).send({ error: read.error })
110
+ if (read.documents.length === 0) return reply.code(400).send({ error: 'The file contains no documents' })
111
+ const caller = identity(req)
112
+ const denied = refusal(caller, kindsNeeded(read.documents), 'write')
113
+ if (denied) return reply.code(403).send({ error: denied })
114
+
115
+ try {
116
+ return await engine.importDocuments(read.documents, { dryRun, actor: options.actor(req), token: caller.apiToken, prune: prune === 'true' })
117
+ } catch (error) {
118
+ if (error instanceof ConfigInvalidError) return reply.code(400).send({ error: error.message, problems: error.problems })
119
+ throw error
120
+ }
121
+ }
122
+
123
+ // Both check the caller before the body is read: parsing a 2 MiB file is work only someone who may import it may ask for.
124
+ // What the caller may import is decided per kind of document once the file is read.
125
+ const mayWriteSomething: onRequestAsyncHookHandler = async (req, reply) => {
126
+ const token = identity(req).apiToken
127
+ if (token && !tokenResources.some((resource) => tokenAllows(token.scopes, `${resource}:write`))) {
128
+ const needs = tokenResources.map((resource) => `"${resource}:write"`)
129
+ return reply.code(403).send({ error: `This token has no permission to import: it needs ${needs.slice(0, -1).join(', ')} or ${needs.at(-1)}` })
130
+ }
131
+ }
132
+ // Only YAML is read, and this is checked before the body is, so a JSON body is never parsed.
133
+ const onlyYaml: onRequestAsyncHookHandler = async (req, reply) => {
134
+ const type = (req.headers['content-type'] ?? '').split(';')[0]!.trim().toLowerCase()
135
+ if (!YAML_TYPES.includes(type)) return reply.code(415).send({ error: 'Send the documents as YAML, with Content-Type: application/yaml' })
136
+ }
137
+ const routeOptions = { onRequest: [...options.guards, mayWriteSomething, onlyYaml], bodyLimit: BODY_LIMIT }
138
+ // What importing the documents would change, without changing anything.
139
+ app.post<{ Body: unknown; Querystring: { prune?: string } }>('/validate', routeOptions, (req, reply) => run(req, reply, true))
140
+ // Makes the installation match the documents, completely or not at all.
141
+ app.post<{ Body: unknown; Querystring: { prune?: string } }>('/apply', routeOptions, (req, reply) => run(req, reply, false))
142
+ }
143
+ }
package/src/yaml.ts ADDED
@@ -0,0 +1,64 @@
1
+ import { Document, parseAllDocuments, visit } from 'yaml'
2
+
3
+ /** Anchors are legitimate in a hand-written file, but a few are plenty and unbounded aliases are a decompression bomb. */
4
+ const MAX_YAML_ALIASES = 20
5
+ /** Documents one file may hold. */
6
+ export const MAX_DOCUMENTS = 2_000
7
+ /** Longest part of a parser error that is repeated to the caller. */
8
+ const MAX_ERROR_LENGTH = 300
9
+
10
+ /**
11
+ * Strings the library leaves bare although a parser reads them as something else: `<<` is the YAML 1.1 merge key (as a
12
+ * key it merges, or fails), and `0o17` is a number in YAML 1.2 but a string in 1.1.
13
+ */
14
+ const NEEDS_QUOTES = /^(<<|=|0o[0-7]+)$/
15
+
16
+ /**
17
+ * No line folding: URLs and templates must stay on one line to be readable and diff well. Version 1.1 makes the
18
+ * writer quote what an older parser would misread ("22:00" is a number there, "yes" and "on" are booleans), so the
19
+ * file means the same to every YAML tool, Terraform's included.
20
+ */
21
+ export function toYaml(value: unknown): string {
22
+ const document = new Document(value, { version: '1.1' })
23
+ visit(document, {
24
+ Scalar(position, node) {
25
+ if (typeof node.value !== 'string' || !NEEDS_QUOTES.test(node.value)) return
26
+ node.type = 'QUOTE_DOUBLE'
27
+ // The 1.1 schema recognises the string `<<` as the merge key and would write it bare whatever the style; as a
28
+ // key it needs an explicit string tag. As a value it is plain text and quoting is enough.
29
+ if (position === 'key' && node.value === '<<') node.tag = 'tag:yaml.org,2002:str'
30
+ },
31
+ })
32
+ return document.toString({ lineWidth: 0 })
33
+ }
34
+
35
+ /** Several documents in one YAML file, separated the usual way. */
36
+ export const toYamlStream = (documents: unknown[]): string => documents.map(toYaml).join('---\n')
37
+
38
+ const clip = (value: string, max: number) => value.length > max ? `${value.slice(0, max)}…` : value
39
+
40
+ /**
41
+ * The documents of a YAML text, in the order they are written (empty ones left out), or the reason it was refused.
42
+ * The text is parsed with the YAML 1.2 core schema; a `%YAML` line would switch to rules under which `yes` is a
43
+ * boolean and `22:00` a number, so it is refused.
44
+ */
45
+ export function readDocuments(body: unknown): { documents: unknown[] } | { error: string } {
46
+ if (typeof body !== 'string') return { error: 'Send the documents as YAML' }
47
+ if (/^%YAML/m.test(body)) return { error: 'Invalid YAML: %YAML directives are not supported' }
48
+ try {
49
+ const documents: unknown[] = []
50
+ for (const document of parseAllDocuments(body)) {
51
+ if (document.errors.length) throw document.errors[0]
52
+ const value: unknown = document.toJS({ maxAliasCount: MAX_YAML_ALIASES })
53
+ // An alias inside the thing it is an alias of (`a: &x { b: *x }`) parses into a circular object, which JSON
54
+ // cannot hold and nothing downstream could compare or store.
55
+ JSON.stringify(value)
56
+ if (value !== null && value !== undefined) documents.push(value)
57
+ if (documents.length > MAX_DOCUMENTS) return { error: `Too many documents: at most ${MAX_DOCUMENTS}` }
58
+ }
59
+ return { documents }
60
+ } catch (error) {
61
+ const circular = error instanceof TypeError && /circular/i.test(error.message)
62
+ return { error: circular ? 'Invalid YAML: an alias may not refer to a value that contains it' : clip(`Invalid YAML: ${error instanceof Error ? error.message : String(error)}`, MAX_ERROR_LENGTH) }
63
+ }
64
+ }