fhirsmith 0.16.1 → 0.18.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/CHANGELOG.md +130 -0
- package/README.md +105 -30
- package/config-template.json +130 -4
- package/extension-tracker/extension-tracker.js +111 -3
- package/extension-tracker/readme.md +3 -0
- package/ips/ai-client.js +263 -0
- package/ips/composer.js +805 -0
- package/ips/editor-schema.js +289 -0
- package/ips/editor.js +290 -0
- package/ips/fhirpath-ips.js +397 -0
- package/ips/ips-codeable-concepts.md +1742 -0
- package/ips/ips-template.html +125 -0
- package/ips/ips-valuesets.js +159 -0
- package/ips/ips.js +1736 -0
- package/ips/membership.js +179 -0
- package/ips/pipeline.js +1012 -0
- package/ips/presenter.js +974 -0
- package/ips/profile-editor.js +578 -0
- package/ips/profiles.js +506 -0
- package/ips/readme.md +460 -0
- package/ips/render.js +1482 -0
- package/ips/search-params.js +83 -0
- package/ips/search.js +667 -0
- package/ips/share.js +178 -0
- package/ips/store.js +650 -0
- package/ips/terminology.js +395 -0
- package/ips/tx-client.js +202 -0
- package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
- package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
- package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
- package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
- package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
- package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
- package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
- package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
- package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
- package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
- package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
- package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
- package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-medication.json +50 -0
- package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
- package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
- package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
- package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
- package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
- package/ips/valuesets/ValueSet-ips-route.json +46 -0
- package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
- package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
- package/ips/valuesets/elements.json +44 -0
- package/library/daily-report.js +422 -0
- package/library/fhir-search-values.js +138 -0
- package/library/html-server.js +32 -23
- package/library/html.js +9 -0
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +222 -0
- package/library/npm-audit.js +25 -0
- package/library/package-manager.js +4 -2
- package/library/sqlite-session-store.js +29 -1
- package/package.json +8 -2
- package/packages/package-crawler.js +189 -82
- package/packages/packages.js +36 -63
- package/packages/utilities/dedupe-versions.js +22 -8
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +48 -1
- package/registry/documentation/api.md +162 -0
- package/registry/documentation/configuration.md +93 -0
- package/registry/documentation/documents.md +74 -0
- package/registry/documentation/internals.md +151 -0
- package/registry/documentation/readme.md +100 -0
- package/registry/documentation/security.md +199 -0
- package/registry/documents.js +309 -0
- package/registry/openapi.yaml +30 -0
- package/registry/registry-template.html +1 -1
- package/registry/registry.js +247 -54
- package/registry/uptime.js +154 -0
- package/release.sh +80 -0
- package/server.js +105 -44
- package/shl/shl.js +64 -9
- package/stats-db.js +93 -0
- package/stats.js +158 -1
- package/testing/readme.md +1 -1
- package/testing/search.js +2 -108
- package/testing/store.js +5 -0
- package/testing/testing.js +20 -0
- package/token/token-template.html +5 -4
- package/token/token.js +17 -10
- package/translations/Messages.properties +9 -0
- package/tx/README.md +52 -0
- package/tx/cm/cm-database.js +62 -0
- package/tx/cm/cm-package.js +20 -3
- package/tx/cs/cs-api.js +90 -14
- package/tx/cs/cs-cs.js +142 -19
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +418 -0
- package/tx/cs/cs-loinc.js +79 -39
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +918 -55
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/library.js +107 -6
- package/tx/ocl/README.md +62 -6
- package/tx/ocl/cm-ocl.cjs +166 -44
- package/tx/ocl/cs-ocl.cjs +163 -13
- package/tx/ocl/resolve/public-access.js +143 -0
- package/tx/ocl/resolve/reference-resolver.js +458 -0
- package/tx/ocl/vs-ocl.cjs +167 -4
- package/tx/openapi-operations.js +4 -0
- package/tx/params.js +8 -1
- package/tx/problems.js +7 -5
- package/tx/provider.js +21 -5
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +37 -8
- package/tx/tx.fhir.org.yml +2 -3
- package/tx/tx.js +148 -5
- package/tx/vs/vs-database.js +26 -0
- package/tx/vs/vs-package.js +42 -10
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/expand.js +34 -7
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/metadata.js +6 -1
- package/tx/workers/subsumes.js +1 -1
- package/tx/workers/translate.js +166 -15
- package/tx/workers/validate.js +128 -6
- package/utilities/make-sct-test-extension.js +264 -0
- package/utilities/submit-test-report.js +1 -1
- package/xig/xig.js +120 -12
- package/gr.sh +0 -3
- package/registry/readme.md +0 -124
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Terminology Server Registry
|
|
2
|
+
|
|
3
|
+
The registry (`/tx-reg`) is the *coordination server* of a terminology server ecosystem: a
|
|
4
|
+
set of FHIR terminology servers, run by different organisations, that together serve the
|
|
5
|
+
code systems and value sets an implementer community needs. Each server hosts some of the
|
|
6
|
+
content and may be *authoritative* for some of it; the registry knows who hosts what, and
|
|
7
|
+
tells clients which server to ask for a given code system or value set. HL7's ecosystem
|
|
8
|
+
registry runs at https://tx.fhir.org/tx-reg (moving to https://ecosystem.hl7.org).
|
|
9
|
+
|
|
10
|
+
| Page | |
|
|
11
|
+
|---|---|
|
|
12
|
+
| this page | What the registry does, and the specifications it implements |
|
|
13
|
+
| [configuration.md](configuration.md) | `modules.registry` settings |
|
|
14
|
+
| [api.md](api.md) | The API: discovery, resolution, and how resolution decides |
|
|
15
|
+
| [security.md](security.md) | Closing the API: API token, users and tokens, OpenID Connect |
|
|
16
|
+
| [documents.md](documents.md) | Documents the registry stores and serves: ecosystem policy and configuration |
|
|
17
|
+
| [internals.md](internals.md) | Scanning, storage, the pages, the code and the tests |
|
|
18
|
+
|
|
19
|
+
## The specifications
|
|
20
|
+
|
|
21
|
+
**The terminology ecosystem IG.** The ecosystem, its registration files and the coordination
|
|
22
|
+
server's API are specified by HL7's
|
|
23
|
+
[FHIR Terminology Ecosystem IG](https://build.fhir.org/ig/HL7/fhir-tx-ecosystem-ig/), on its
|
|
24
|
+
[ecosystem page](https://build.fhir.org/ig/HL7/fhir-tx-ecosystem-ig/ecosystem.html). That page
|
|
25
|
+
is normative for what the registry answers; where this implementation extends or departs from
|
|
26
|
+
it, [api.md](api.md) says so.
|
|
27
|
+
|
|
28
|
+
**The registration files.** The ecosystem is declared by a *master registration file* that
|
|
29
|
+
lists *registries*, each of which lists *servers*, each of which lists its endpoints (one per
|
|
30
|
+
FHIR version) and its claims. The format is documented in the ig-registry repository's
|
|
31
|
+
[tx-registry-doco.md](https://github.com/FHIR/ig-registry/blob/master/tx-registry-doco.md).
|
|
32
|
+
HL7's master file is https://fhir.github.io/ig-registry/tx-servers.json, and its registries
|
|
33
|
+
are the per-affiliate `*-tx-servers.json` files in the same repository (HL7 International,
|
|
34
|
+
HL7 Australia, HL7 Europe, and so on). A server's registration carries:
|
|
35
|
+
|
|
36
|
+
* `authoritative` and `authoritative-valuesets`: URL masks (with `*` wildcards) of the code
|
|
37
|
+
systems and value sets the server is the authority for;
|
|
38
|
+
* `languages`: language specific authority claims, BCP-47 tag to code system masks;
|
|
39
|
+
* `usage`: if present, the only purposes the server may be used for (the HL7 Java tools say
|
|
40
|
+
`publication`, `validation` or `code-generation`);
|
|
41
|
+
* `exclusions`: code system and value set masks the server hosts but withholds from the
|
|
42
|
+
ecosystem;
|
|
43
|
+
* `fhirVersions`: the endpoints, each a FHIR version and a URL.
|
|
44
|
+
|
|
45
|
+
**What the registry reads from the servers.** Claims are what a server says about itself; what
|
|
46
|
+
it actually hosts, the registry learns by scanning each endpoint with standard FHIR:
|
|
47
|
+
|
|
48
|
+
* [`GET [base]/metadata`](https://hl7.org/fhir/http.html#capabilities), the
|
|
49
|
+
[CapabilityStatement](https://hl7.org/fhir/capabilitystatement.html), for
|
|
50
|
+
`software.name`, `software.version` and `software.releaseDate` (the software page);
|
|
51
|
+
* `GET [base]/metadata?mode=terminology`, the
|
|
52
|
+
[TerminologyCapabilities](https://hl7.org/fhir/terminologycapabilities.html), for the code
|
|
53
|
+
systems and versions the endpoint hosts (`codeSystem[].uri`, `codeSystem[].version[].code`);
|
|
54
|
+
* `GET [base]/ValueSet?_elements=url,version`, a [search](https://hl7.org/fhir/search.html)
|
|
55
|
+
for the value sets it hosts.
|
|
56
|
+
|
|
57
|
+
Content a server hosts is what makes it a *candidate* for that content; a claim makes it
|
|
58
|
+
*authoritative*. A server is never returned for content it does not host, whatever it claims.
|
|
59
|
+
|
|
60
|
+
**FHIR versions.** Endpoints are scanned as R3, R4 (including R4B) or R5 by the major version
|
|
61
|
+
in their registration, and requests may name a version as a release code (`R4`, `R5`) or a
|
|
62
|
+
number (`4.0.1`, `4.0`).
|
|
63
|
+
|
|
64
|
+
**SNOMED CT.** SNOMED CT versions are edition URIs,
|
|
65
|
+
`http://snomed.info/sct/{edition}/version/{date}`
|
|
66
|
+
([SNOMED CT URI standard](https://confluence.ihtsdotools.org/display/DOCURI)). The registry
|
|
67
|
+
treats a request for an edition (`http://snomed.info/sct|http://snomed.info/sct/32506021000036107`)
|
|
68
|
+
as satisfied by any endpoint hosting any version of that edition; a request for a dated
|
|
69
|
+
version must match exactly.
|
|
70
|
+
|
|
71
|
+
**The API description.** The API is described by an [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0)
|
|
72
|
+
document at `/tx-reg/openapi.json` (and `.yaml`), with a browsable reference at
|
|
73
|
+
`/tx-reg/openapi`. Every response advertises it with a
|
|
74
|
+
`Link: </tx-reg/openapi.json>; rel="service-desc"` header
|
|
75
|
+
([RFC 8631](https://www.rfc-editor.org/rfc/rfc8631)).
|
|
76
|
+
|
|
77
|
+
**Security.** When the API is closed, clients present a token as an HTTP bearer token
|
|
78
|
+
([RFC 6750](https://www.rfc-editor.org/rfc/rfc6750)) or an `Api-Key` header. Enterprise
|
|
79
|
+
clients can present a JWT ([RFC 7519](https://www.rfc-editor.org/rfc/rfc7519)) from an
|
|
80
|
+
[OpenID Connect](https://openid.net/specs/openid-connect-core-1_0.html) provider, which the
|
|
81
|
+
registry verifies against the provider's keys
|
|
82
|
+
([OpenID Connect Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html),
|
|
83
|
+
[JWK, RFC 7517](https://www.rfc-editor.org/rfc/rfc7517)), typically obtained with the OAuth 2.0
|
|
84
|
+
client credentials grant ([RFC 6749 §4.4](https://www.rfc-editor.org/rfc/rfc6749#section-4.4)).
|
|
85
|
+
See [security.md](security.md).
|
|
86
|
+
|
|
87
|
+
## Who uses it
|
|
88
|
+
|
|
89
|
+
The main client is the HL7 Java tooling: `TerminologyClientManager` in `org.hl7.fhir.r5`
|
|
90
|
+
(used by the validator, the IG publisher and everything built on them) calls `/resolve` for
|
|
91
|
+
each code system or value set it meets, remembers the answer per code system and language, and
|
|
92
|
+
routes each terminology operation to the server the registry named. Other clients - the
|
|
93
|
+
registry's own pages, scripts, other servers - use the same two endpoints.
|
|
94
|
+
|
|
95
|
+
## Running one
|
|
96
|
+
|
|
97
|
+
A registry is a FHIRsmith server with the `registry` module enabled; nothing else is needed
|
|
98
|
+
([configuration.md](configuration.md)). Pointing `masterUrl` at your own master registration
|
|
99
|
+
file makes a registry for your own ecosystem - a national or enterprise set of servers - with
|
|
100
|
+
the same API, and [security.md](security.md) closes it to your own clients.
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# Securing the registry
|
|
2
|
+
|
|
3
|
+
By default the registry is open, as HL7's ecosystem registry is: anyone can call the API. A
|
|
4
|
+
registry run for a narrower community - an enterprise, a national programme, a vendor's
|
|
5
|
+
customers - can be closed with `modules.registry.security`, in any combination of three ways:
|
|
6
|
+
|
|
7
|
+
| Method | Who it suits |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `apiToken` | A handful of clients you configure yourself: one token, every client sends it |
|
|
10
|
+
| `users` | Clients you want to tell apart - issue, expire and revoke tokens one by one, see who is calling |
|
|
11
|
+
| `oidc` | An enterprise with an identity provider: it decides who may call, and issues the tokens |
|
|
12
|
+
|
|
13
|
+
The methods combine: a registry can accept its enterprise's JWTs *and* have a few locally
|
|
14
|
+
managed users for clients that can't do OAuth. The users are mostly software - a validator
|
|
15
|
+
in a build pipeline, a server resolving terminology - not people, and the design follows
|
|
16
|
+
from that.
|
|
17
|
+
|
|
18
|
+
## What is closed
|
|
19
|
+
|
|
20
|
+
Only the JSON API: `GET /tx-reg/` and `GET /tx-reg/resolve` when they answer JSON. The HTML
|
|
21
|
+
pages (home, resolve form, software, log), the OpenAPI description and the login page stay
|
|
22
|
+
open, so a client - or the person configuring one - can always find out how to authenticate.
|
|
23
|
+
A request without a valid token gets:
|
|
24
|
+
|
|
25
|
+
```http
|
|
26
|
+
HTTP/1.1 401 Unauthorized
|
|
27
|
+
WWW-Authenticate: Bearer realm="tx-reg"
|
|
28
|
+
Content-Type: application/json
|
|
29
|
+
|
|
30
|
+
{ "error": "unauthorized",
|
|
31
|
+
"message": "This registry requires a token: send it as Authorization: Bearer {token} or Api-Key: {token}",
|
|
32
|
+
"documentation": "/tx-reg/openapi" }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
(The message says `The token is not valid for this registry` when one was sent.) An open
|
|
36
|
+
registry ignores any token sent to it.
|
|
37
|
+
|
|
38
|
+
Failed authentications are limited per address. After `failedAuthPerWindow` requests
|
|
39
|
+
answered 401 in 15 minutes (default 60), the address's requests with a token that isn't
|
|
40
|
+
valid get `429 Too Many Requests` for the rest of the window. The token is checked first, so a
|
|
41
|
+
request with a valid token always goes through, even from an address over the limit. One
|
|
42
|
+
client behind a NAT retrying a bad token doesn't lock out the others. Only 401s count. This
|
|
43
|
+
stops anyone guessing tokens. JWTs without an `exp` claim are refused. General request volume is still the proxy's
|
|
44
|
+
business (nginx). `0` turns the limit off. It applies to the JSON API only; the pages
|
|
45
|
+
aren't guarded, so they aren't limited.
|
|
46
|
+
|
|
47
|
+
## How a client sends a token
|
|
48
|
+
|
|
49
|
+
As an HTTP bearer token, or in an `Api-Key` header (`X-API-Key` is accepted too):
|
|
50
|
+
|
|
51
|
+
```http
|
|
52
|
+
GET /tx-reg/resolve?fhirVersion=R4&url=http://loinc.org
|
|
53
|
+
Authorization: Bearer txr_k3Jd9...
|
|
54
|
+
|
|
55
|
+
GET /tx-reg/resolve?fhirVersion=R4&url=http://loinc.org
|
|
56
|
+
Api-Key: txr_k3Jd9...
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The HL7 Java tooling (validator, IG publisher, and everything that uses
|
|
60
|
+
`TerminologyClientManager`) sends either for a server listed in its `fhir-settings.json`, so a
|
|
61
|
+
secured registry needs no change to the tools, only a settings entry:
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{ "servers": [
|
|
65
|
+
{ "url": "https://ecosystem.example.org/tx-reg",
|
|
66
|
+
"authenticationType": "token", "token": "txr_k3Jd9..." }
|
|
67
|
+
] }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
(`"authenticationType": "apikey", "apikey": "..."` sends the `Api-Key` header instead.) Note
|
|
71
|
+
that the tooling can *send* a token but not *obtain* one, so a Java client of an OIDC-secured
|
|
72
|
+
registry needs a long-lived token - from a local user, or an OIDC client credential arranged
|
|
73
|
+
outside the tool.
|
|
74
|
+
|
|
75
|
+
## Method 1: a single API token
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
"security": { "apiToken": "a long random string" }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Every client sends the same token. Compared in constant time; nothing is stored. Rotating it
|
|
82
|
+
means changing the configuration and every client at once, which is why this suits only a few
|
|
83
|
+
clients you control.
|
|
84
|
+
|
|
85
|
+
## Method 2: users with their own tokens
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
"security": {
|
|
89
|
+
"users": {
|
|
90
|
+
"database": "registry-auth.db",
|
|
91
|
+
"adminUsername": "admin",
|
|
92
|
+
"adminPassword": "...",
|
|
93
|
+
"sessionSecret": "a long random string",
|
|
94
|
+
"cookieSecure": true,
|
|
95
|
+
"loginAttemptsPerWindow": 20
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Users are kept in a SQLite database (`database`: a file in the data folder's `databases`
|
|
101
|
+
directory, an absolute path, or `:memory:`). The administrator's login comes from the
|
|
102
|
+
configuration, as in the other FHIRsmith modules; without `adminPassword` nobody can create
|
|
103
|
+
users. Sessions are kept in the same database, so with a configured `sessionSecret` logins
|
|
104
|
+
survive a restart. Changing or removing a user's password ends that user's other sessions.
|
|
105
|
+
Pages that a visitor only reads (documents and their history) start no session for them.
|
|
106
|
+
`cookieSecure` (default true) marks
|
|
107
|
+
the login cookie `Secure`, which needs HTTPS and a proxy that passes `X-Forwarded-Proto`
|
|
108
|
+
([configuration.md](configuration.md#behind-a-proxy)). Logins are rate limited to
|
|
109
|
+
`loginAttemptsPerWindow` per 15 minutes per address.
|
|
110
|
+
|
|
111
|
+
**Administering users.** The administrator logs in at `/tx-reg/login` and manages users at
|
|
112
|
+
`/tx-reg/admin/users`: create a user (a login, a name, optional notes and password, and
|
|
113
|
+
whether it is an administrator), then on the user's page edit it, enable or disable it, set or
|
|
114
|
+
remove its password, issue and revoke tokens, or delete it. A user flagged **administrator**
|
|
115
|
+
can do all of this too, and edit [documents](documents.md); the configured administrator is
|
|
116
|
+
the root that cannot be locked out. The users list shows each user's active tokens, request count and last
|
|
117
|
+
use; an "other clients" table shows requests made with the API token and by OIDC clients.
|
|
118
|
+
|
|
119
|
+
**Tokens.** A token is `txr_` followed by 256 random bits (base64url, 47 characters). It is
|
|
120
|
+
shown once, on the page after it is issued, and only its SHA-256 hash is stored - a stolen
|
|
121
|
+
database yields no tokens, and the hash (rather than bcrypt) is right because the token is
|
|
122
|
+
already high-entropy and is checked on every API call. A token can carry a label and an
|
|
123
|
+
expiry (in days); it stops working when it expires, when it is revoked, or when its user is
|
|
124
|
+
disabled. Each token's requests are counted, in memory, and written to the database once a
|
|
125
|
+
minute. The first 12 characters are kept in clear as the token's name on the pages.
|
|
126
|
+
|
|
127
|
+
**Self service.** A user with a password can log in and manage its own tokens at
|
|
128
|
+
`/tx-reg/account`: see them and their usage, issue new ones, revoke old ones, and change the
|
|
129
|
+
password. To rotate a token without an outage: issue the new one, move the client to it,
|
|
130
|
+
revoke the old one. A user without a password cannot log in at all; its tokens still work.
|
|
131
|
+
The password is only for the pages - it is never accepted on the API.
|
|
132
|
+
|
|
133
|
+
**Web security.** The pages use the same protections as the other FHIRsmith modules: a
|
|
134
|
+
`SameSite=Lax`, `HttpOnly` session cookie; a CSRF token on every form; cross-site form posts
|
|
135
|
+
refused by `Origin`; bcrypt for passwords, with the same comparison time whether or not the
|
|
136
|
+
username exists; the session regenerated at login; every value from the database escaped on
|
|
137
|
+
output. Logins, user changes and token issue/revocation are logged.
|
|
138
|
+
|
|
139
|
+
## Method 3: OpenID Connect
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
"security": {
|
|
143
|
+
"oidc": {
|
|
144
|
+
"issuer": "https://login.microsoftonline.com/{tenant}/v2.0",
|
|
145
|
+
"audience": "api://ecosystem.example.org",
|
|
146
|
+
"requiredScope": "registry.read",
|
|
147
|
+
"jwksUri": "https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys",
|
|
148
|
+
"algorithms": ["RS256"]
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The enterprise's identity provider (Entra ID, Okta, Keycloak, Ping, Auth0 - anything that
|
|
154
|
+
implements OpenID Connect) issues JWTs; the registry verifies them and lets the bearer in. The
|
|
155
|
+
enterprise therefore decides who may use the registry, with its own onboarding and
|
|
156
|
+
offboarding, and no secret is ever shared with the registry.
|
|
157
|
+
|
|
158
|
+
A software client registers an application with the provider and obtains a token with the
|
|
159
|
+
OAuth 2.0 client credentials grant, then sends it as `Authorization: Bearer`. (A person's
|
|
160
|
+
token from the usual browser login flow works the same way, if it has the right audience.)
|
|
161
|
+
|
|
162
|
+
**What is checked.** The signature, against the issuer's published keys; `iss` equal to
|
|
163
|
+
`issuer`; `aud` containing `audience` (a string, or a list of acceptable values); `exp` and
|
|
164
|
+
`nbf`, with 60 seconds' tolerance; and, if `requiredScope` is set, that value among the
|
|
165
|
+
token's scopes - the space-separated `scp` or `scope` claim, or the `roles` list (Entra ID puts
|
|
166
|
+
application permissions in `roles`). Any failure is a 401, and the reason is logged.
|
|
167
|
+
|
|
168
|
+
**Keys.** With `jwksUri`, keys are fetched from there. Without it, the registry reads
|
|
169
|
+
`{issuer}/.well-known/openid-configuration` on the first request and uses its `jwks_uri`;
|
|
170
|
+
if that fails the request is refused and discovery is retried on the next. Keys are cached
|
|
171
|
+
and refreshed when an unknown key id appears (at most every 30 seconds). Both URLs must be
|
|
172
|
+
https. `algorithms` restricts the accepted signing algorithms; the default is the asymmetric
|
|
173
|
+
set (RSA, RSA-PSS, ECDSA, EdDSA) - symmetric HMAC algorithms are never accepted, since a shared
|
|
174
|
+
secret would defeat the point.
|
|
175
|
+
|
|
176
|
+
**Identifying the client.** Requests are counted per client, by the token's `azp`,
|
|
177
|
+
`client_id`, `appid` or `sub` claim, and shown in the "other clients" table on the users page
|
|
178
|
+
when `users` is also configured. The registry keeps no record of OIDC clients beyond that.
|
|
179
|
+
|
|
180
|
+
**Which check a token gets.** A token of three dot-separated parts is treated as a JWT and
|
|
181
|
+
verified against the provider when `oidc` is configured; anything else is compared with
|
|
182
|
+
`apiToken`, then looked up among the users' tokens. So the three methods never confuse one
|
|
183
|
+
another's tokens.
|
|
184
|
+
|
|
185
|
+
This needs the `jose` package (a dependency of FHIRsmith); a registry configured for OIDC
|
|
186
|
+
without it refuses to start, with a message saying so.
|
|
187
|
+
|
|
188
|
+
## What is not done, and could be
|
|
189
|
+
|
|
190
|
+
* **Trusted proxy headers.** An enterprise that fronts the registry with its own gateway
|
|
191
|
+
(mTLS, SSO, API keys it already issues) could pass the caller's identity in a header the
|
|
192
|
+
registry trusts from that proxy only. Not implemented; the gateway can simply hold the
|
|
193
|
+
registry's API token today.
|
|
194
|
+
* **Single sign-on for the pages.** The administrator and users log in with passwords. The
|
|
195
|
+
OIDC provider could authenticate people to the pages too.
|
|
196
|
+
* **Token introspection, LDAP, SCIM.** Considered and set aside until a deployment needs one.
|
|
197
|
+
Introspection (RFC 7662) suits opaque enterprise tokens at the cost of a call per request;
|
|
198
|
+
LDAP is a human-login technology; SCIM would synchronise the enterprise's users into method
|
|
199
|
+
2 but still leave the tokens to be issued here.
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// registry/documents.js
|
|
2
|
+
// Documents the registry stores and serves: enterprise configuration and policy, as
|
|
3
|
+
// markdown or JSON. Each has a name, a type, whether it is public, and when (and by whom)
|
|
4
|
+
// it was last edited; every change is logged with the content as it was.
|
|
5
|
+
//
|
|
6
|
+
// public documents anyone can read
|
|
7
|
+
// other documents anyone logged in (a browser session - never an API token)
|
|
8
|
+
// editing, deleting administrators (the configured one, or a user flagged admin)
|
|
9
|
+
//
|
|
10
|
+
// Markdown is rendered without raw HTML (commonmark's safe mode: inline and block HTML is
|
|
11
|
+
// left out, and links to javascript: and the like are not made), so a document can't put
|
|
12
|
+
// script on a page that shares its origin with other modules; JSON is shown escaped.
|
|
13
|
+
// Editing is a textarea.
|
|
14
|
+
//
|
|
15
|
+
// Routes (added to the registry's router by RegistryAuth, since they need its session):
|
|
16
|
+
// GET /docs the list
|
|
17
|
+
// GET /docs/new, POST /docs create
|
|
18
|
+
// GET /docs/{name} the document: rendered for a browser, raw otherwise
|
|
19
|
+
// GET /docs/{name}/edit, POST edit (name, type, public, content)
|
|
20
|
+
// POST /docs/{name}/delete
|
|
21
|
+
// GET /docs/{name}/history the log; /history/{entry} shows a past version
|
|
22
|
+
|
|
23
|
+
const express = require('express');
|
|
24
|
+
const escape = require('escape-html');
|
|
25
|
+
const commonmark = require('commonmark');
|
|
26
|
+
|
|
27
|
+
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,99}$/i;
|
|
28
|
+
const RESERVED = new Set(['new']);
|
|
29
|
+
const TYPES = { md: 'text/markdown; charset=utf-8', json: 'application/json; charset=utf-8' };
|
|
30
|
+
const MAX_SIZE = 2 * 1024 * 1024;
|
|
31
|
+
|
|
32
|
+
function str(v) {
|
|
33
|
+
return typeof v === 'string' ? v : '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Render markdown to HTML: no raw HTML, and no links to javascript: and the like */
|
|
37
|
+
function renderMarkdown(md) {
|
|
38
|
+
const parsed = new commonmark.Parser().parse(md);
|
|
39
|
+
return new commonmark.HtmlRenderer({ safe: true }).render(parsed);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The JSON pretty-printed, or null if it doesn't parse */
|
|
43
|
+
function prettyJson(text) {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.stringify(JSON.parse(text), null, 2);
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class RegistryDocuments {
|
|
52
|
+
/**
|
|
53
|
+
* @param {RegistryAuth} auth the session, csrf and store live there
|
|
54
|
+
* @param {object} logger
|
|
55
|
+
*/
|
|
56
|
+
constructor(auth, logger) {
|
|
57
|
+
this.auth = auth;
|
|
58
|
+
this.store = auth.store;
|
|
59
|
+
this.log = logger || console;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setupRoutes(router) {
|
|
63
|
+
const a = this.auth;
|
|
64
|
+
const h = fn => (req, res) => Promise.resolve().then(() => fn(req, res)).catch(e => {
|
|
65
|
+
this.log.error(`Registry documents: ${e.message}`);
|
|
66
|
+
a.sendError(req, res, 500, e.message);
|
|
67
|
+
});
|
|
68
|
+
const admin = (req, res, next) => {
|
|
69
|
+
const user = a.currentUser(req);
|
|
70
|
+
if (!user) {
|
|
71
|
+
return res.redirect(req.baseUrl + '/login');
|
|
72
|
+
}
|
|
73
|
+
if (!user.isAdmin) {
|
|
74
|
+
return a.sendError(req, res, 403, 'Only an administrator can do that');
|
|
75
|
+
}
|
|
76
|
+
req.user = user;
|
|
77
|
+
next();
|
|
78
|
+
};
|
|
79
|
+
// the session is read on every page, so that a logged in user sees the private
|
|
80
|
+
// documents; CSRF only matters for the forms
|
|
81
|
+
router.get('/docs', a.session, a.csrfIfLoggedIn, h((req, res) => this.sendList(req, res)));
|
|
82
|
+
router.get('/docs/new', a.session, a.csrf, admin, h((req, res) => this.sendForm(req, res, null)));
|
|
83
|
+
// a document's form carries up to MAX_SIZE of content, URL-encoded (up to three times as
|
|
84
|
+
// long): the auth pages' 64kb form parser is too small for it. One a bit over is caught
|
|
85
|
+
// by save(), with the form's own message
|
|
86
|
+
const docForm = express.urlencoded({ extended: false, limit: 3 * MAX_SIZE + 64 * 1024 });
|
|
87
|
+
router.post('/docs', a.sameOrigin, a.session, docForm, a.csrf, admin, h((req, res) => this.save(req, res, null)));
|
|
88
|
+
router.get('/docs/:name', a.session, a.csrfIfLoggedIn, h((req, res) => this.sendDocument(req, res)));
|
|
89
|
+
router.get('/docs/:name/edit', a.session, a.csrf, admin, h((req, res) => this.sendForm(req, res, this.find(req))));
|
|
90
|
+
router.post('/docs/:name', a.sameOrigin, a.session, docForm, a.csrf, admin, h((req, res) => this.save(req, res, this.find(req))));
|
|
91
|
+
router.post('/docs/:name/delete', a.sameOrigin, a.session, a.form, a.csrf, admin, h((req, res) => this.delete(req, res)));
|
|
92
|
+
router.get('/docs/:name/history', a.session, a.csrfIfLoggedIn, h((req, res) => this.sendHistory(req, res)));
|
|
93
|
+
router.get('/docs/:name/history/:entry', a.session, a.csrfIfLoggedIn, h((req, res) => this.sendVersion(req, res)));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
find(req) {
|
|
97
|
+
const name = str(req.params.name);
|
|
98
|
+
return NAME_RE.test(name) ? this.store.documentByName(name) : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* May this request read the document - or a past version of it (a log entry, which has
|
|
103
|
+
* its own public flag)? Public: anyone; else a logged in user
|
|
104
|
+
*/
|
|
105
|
+
canRead(req, doc) {
|
|
106
|
+
return !!doc.public || !!this.auth.currentUser(req);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
wantsHtml(req) {
|
|
110
|
+
return !!(req.headers.accept && req.headers.accept.includes('text/html'));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---- reading ----
|
|
114
|
+
|
|
115
|
+
sendList(req, res) {
|
|
116
|
+
const base = escape(req.baseUrl || '');
|
|
117
|
+
const user = this.auth.currentUser(req);
|
|
118
|
+
const docs = this.store.documents().filter(d => d.public || user);
|
|
119
|
+
let h = '';
|
|
120
|
+
if (user && user.isAdmin) {
|
|
121
|
+
h += `<p style="float: right"><a class="btn btn-primary btn-sm" href="${base}/docs/new">New document</a></p>`;
|
|
122
|
+
}
|
|
123
|
+
h += '<p>Documents this registry publishes: configuration and policy for its ecosystem. ' +
|
|
124
|
+
(user ? '' : 'Documents that are not public are shown to logged in users.') + '</p>';
|
|
125
|
+
h += '<table class="grid"><thead><tr><th>Name</th><th>Type</th><th>Visibility</th><th>Last edited</th><th>By</th><th>Size</th></tr></thead><tbody>';
|
|
126
|
+
for (const d of docs) {
|
|
127
|
+
h += `<tr><td><a href="${base}/docs/${encodeURIComponent(d.name)}">${escape(d.name)}</a></td><td>${escape(d.type)}</td>` +
|
|
128
|
+
`<td>${d.public ? 'public' : 'logged in users'}</td><td>${escape(d.updated_at.substring(0, 16).replace('T', ' '))}</td>` +
|
|
129
|
+
`<td>${escape(d.updated_by)}</td><td>${d.size}</td></tr>`;
|
|
130
|
+
}
|
|
131
|
+
if (docs.length === 0) {
|
|
132
|
+
h += '<tr><td colspan="6"><i>no documents</i></td></tr>';
|
|
133
|
+
}
|
|
134
|
+
h += '</tbody></table>';
|
|
135
|
+
this.auth.renderPage(req, res, 'Documents', h, 200);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
sendDocument(req, res) {
|
|
139
|
+
const doc = this.find(req);
|
|
140
|
+
if (!doc) {
|
|
141
|
+
return this.notFound(req, res);
|
|
142
|
+
}
|
|
143
|
+
if (!this.canRead(req, doc)) {
|
|
144
|
+
return this.denied(req, res);
|
|
145
|
+
}
|
|
146
|
+
if (!this.wantsHtml(req)) {
|
|
147
|
+
res.setHeader('Content-Type', TYPES[doc.type] || 'text/plain; charset=utf-8');
|
|
148
|
+
res.setHeader('Last-Modified', new Date(doc.updated_at).toUTCString());
|
|
149
|
+
res.setHeader('Cache-Control', doc.public ? 'public, max-age=60' : 'private, no-store');
|
|
150
|
+
return res.send(doc.content);
|
|
151
|
+
}
|
|
152
|
+
const base = escape(req.baseUrl || '');
|
|
153
|
+
const user = this.auth.currentUser(req);
|
|
154
|
+
let h = `<p class="text-muted"><small>${escape(doc.type === 'md' ? 'Markdown' : 'JSON')} · ` +
|
|
155
|
+
`${doc.public ? 'public' : 'logged in users'} · last edited ${escape(doc.updated_at.substring(0, 16).replace('T', ' '))} by ${escape(doc.updated_by)} · ` +
|
|
156
|
+
`<a href="${base}/docs/${encodeURIComponent(doc.name)}/history">history</a>`;
|
|
157
|
+
if (user && user.isAdmin) {
|
|
158
|
+
h += ` · <a href="${base}/docs/${encodeURIComponent(doc.name)}/edit">edit</a>`;
|
|
159
|
+
}
|
|
160
|
+
h += '</small></p>';
|
|
161
|
+
h += this.renderContent(doc);
|
|
162
|
+
this.auth.renderPage(req, res, doc.name, h, 200);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
renderContent(doc) {
|
|
166
|
+
if (doc.type === 'json') {
|
|
167
|
+
const pretty = prettyJson(doc.content);
|
|
168
|
+
return `<pre class="json">${escape(pretty === null ? doc.content : pretty)}</pre>`;
|
|
169
|
+
}
|
|
170
|
+
return `<div class="document">${renderMarkdown(doc.content)}</div>`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
sendHistory(req, res) {
|
|
174
|
+
const doc = this.find(req);
|
|
175
|
+
if (!doc) {
|
|
176
|
+
return this.notFound(req, res);
|
|
177
|
+
}
|
|
178
|
+
if (!this.canRead(req, doc)) {
|
|
179
|
+
return this.denied(req, res);
|
|
180
|
+
}
|
|
181
|
+
const base = escape(req.baseUrl || '');
|
|
182
|
+
const url = `${base}/docs/${encodeURIComponent(doc.name)}`;
|
|
183
|
+
let h = `<p><a href="${url}">« ${escape(doc.name)}</a></p>`;
|
|
184
|
+
h += '<table class="grid"><thead><tr><th>When</th><th>Who</th><th>Action</th><th>Name</th><th>Type</th><th>Visibility</th><th>Size</th></tr></thead><tbody>';
|
|
185
|
+
// the versions this request may read: a version from when the document was private
|
|
186
|
+
// stays private, whatever the document is now
|
|
187
|
+
const versions = this.store.documentLog(doc.id).filter(e => this.canRead(req, e));
|
|
188
|
+
for (const e of versions) {
|
|
189
|
+
h += `<tr><td><a href="${url}/history/${e.id}">${escape(e.at.substring(0, 19).replace('T', ' '))}</a></td><td>${escape(e.by)}</td>` +
|
|
190
|
+
`<td>${escape(e.action)}</td><td>${escape(e.name)}</td><td>${escape(e.type)}</td><td>${e.public ? 'public' : 'logged in users'}</td><td>${e.size}</td></tr>`;
|
|
191
|
+
}
|
|
192
|
+
h += '</tbody></table>';
|
|
193
|
+
this.auth.renderPage(req, res, `History of ${doc.name}`, h, 200);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
sendVersion(req, res) {
|
|
197
|
+
const doc = this.find(req);
|
|
198
|
+
if (!doc) {
|
|
199
|
+
return this.notFound(req, res);
|
|
200
|
+
}
|
|
201
|
+
if (!this.canRead(req, doc)) {
|
|
202
|
+
return this.denied(req, res);
|
|
203
|
+
}
|
|
204
|
+
const entry = this.store.documentLogEntry(doc.id, Number(req.params.entry));
|
|
205
|
+
if (!entry) {
|
|
206
|
+
return this.notFound(req, res);
|
|
207
|
+
}
|
|
208
|
+
if (!this.canRead(req, entry)) {
|
|
209
|
+
return this.denied(req, res);
|
|
210
|
+
}
|
|
211
|
+
const base = escape(req.baseUrl || '');
|
|
212
|
+
let h = `<p><a href="${base}/docs/${encodeURIComponent(doc.name)}/history">« history</a></p>`;
|
|
213
|
+
h += `<div class="alert alert-info">The document as ${escape(entry.action)} at ${escape(entry.at.substring(0, 19).replace('T', ' '))} by ${escape(entry.by)}` +
|
|
214
|
+
(entry.name !== doc.name ? `, then named <code>${escape(entry.name)}</code>` : '') + '.</div>';
|
|
215
|
+
h += this.renderContent(entry);
|
|
216
|
+
this.auth.renderPage(req, res, `${doc.name} at ${entry.at.substring(0, 16).replace('T', ' ')}`, h, 200);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
notFound(req, res) {
|
|
220
|
+
if (this.wantsHtml(req)) {
|
|
221
|
+
return this.auth.sendError(req, res, 404, 'No such document');
|
|
222
|
+
}
|
|
223
|
+
res.status(404).json({ error: 'not-found', message: 'No such document' });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// a document that exists but is not public looks the same as one that doesn't, to a
|
|
227
|
+
// stranger: a browser is sent to log in, anything else gets 404
|
|
228
|
+
denied(req, res) {
|
|
229
|
+
if (this.wantsHtml(req)) {
|
|
230
|
+
return res.redirect(`${req.baseUrl || ''}/login`);
|
|
231
|
+
}
|
|
232
|
+
res.status(404).json({ error: 'not-found', message: 'No such document' });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---- editing ----
|
|
236
|
+
|
|
237
|
+
sendForm(req, res, doc, values, error) {
|
|
238
|
+
if (req.params.name && !doc) {
|
|
239
|
+
return this.notFound(req, res);
|
|
240
|
+
}
|
|
241
|
+
const base = escape(req.baseUrl || '');
|
|
242
|
+
const v = values || (doc ? { name: doc.name, type: doc.type, public: doc.public, content: doc.content } : { name: '', type: 'md', public: false, content: '' });
|
|
243
|
+
const action = doc ? `${base}/docs/${encodeURIComponent(doc.name)}` : `${base}/docs`;
|
|
244
|
+
let h = error ? `<div class="alert alert-danger">${escape(error)}</div>` : '';
|
|
245
|
+
h += `<form method="post" action="${action}"><input type="hidden" name="_csrf" value="${escape(res.locals._csrf || '')}"/>` +
|
|
246
|
+
'<div class="row g-3" style="max-width: 900px">' +
|
|
247
|
+
`<div class="col-md-5"><label class="form-label" for="d-name">Name</label><input class="form-control" id="d-name" name="name" value="${escape(v.name)}" required maxlength="100" pattern="[A-Za-z0-9][A-Za-z0-9._-]*"/>` +
|
|
248
|
+
'<div class="form-text">Letters, digits, dots, hyphens and underscores; it is the document\'s address</div></div>' +
|
|
249
|
+
'<div class="col-md-3"><label class="form-label" for="d-type">Type</label><select class="form-select" id="d-type" name="type">' +
|
|
250
|
+
`<option value="md"${v.type === 'md' ? ' selected' : ''}>Markdown</option><option value="json"${v.type === 'json' ? ' selected' : ''}>JSON</option></select></div>` +
|
|
251
|
+
`<div class="col-md-4"><label class="form-label"> </label><div><label><input type="checkbox" name="public" value="1"${v.public ? ' checked' : ''}/> Public (anyone can read it)</label></div></div>` +
|
|
252
|
+
`<div class="col-12"><label class="form-label" for="d-content">Content</label><textarea class="form-control" id="d-content" name="content" rows="28" style="font-family: monospace" spellcheck="false">${escape(v.content)}</textarea></div>` +
|
|
253
|
+
`<div class="col-12"><button type="submit" class="btn btn-primary">${doc ? 'Save' : 'Create'}</button> ` +
|
|
254
|
+
`<a class="btn btn-secondary" href="${doc ? action : base + '/docs'}">Cancel</a></div></div></form>`;
|
|
255
|
+
if (doc) {
|
|
256
|
+
h += `<form method="post" action="${action}/delete" style="margin-top: 2em" onsubmit="return confirm('Delete this document?')">` +
|
|
257
|
+
`<input type="hidden" name="_csrf" value="${escape(res.locals._csrf || '')}"/><button type="submit" class="btn btn-outline-danger btn-sm">Delete document</button></form>`;
|
|
258
|
+
}
|
|
259
|
+
this.auth.renderPage(req, res, doc ? `Edit ${doc.name}` : 'New document', h, error ? 400 : 200);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
save(req, res, doc) {
|
|
263
|
+
if (req.params.name && !doc) {
|
|
264
|
+
return this.notFound(req, res);
|
|
265
|
+
}
|
|
266
|
+
const values = {
|
|
267
|
+
name: str(req.body.name).trim(),
|
|
268
|
+
type: str(req.body.type) === 'json' ? 'json' : 'md',
|
|
269
|
+
public: req.body.public === '1',
|
|
270
|
+
content: str(req.body.content).replace(/\r\n/g, '\n')
|
|
271
|
+
};
|
|
272
|
+
let error = null;
|
|
273
|
+
if (!NAME_RE.test(values.name) || RESERVED.has(values.name.toLowerCase())) {
|
|
274
|
+
error = 'The name must be 1-100 letters, digits, dots, hyphens or underscores, starting with a letter or digit';
|
|
275
|
+
} else if (values.content.length > MAX_SIZE) {
|
|
276
|
+
error = `The document is too large (${values.content.length} characters; the limit is ${MAX_SIZE})`;
|
|
277
|
+
} else if (values.type === 'json' && prettyJson(values.content) === null) {
|
|
278
|
+
error = 'The content is not valid JSON';
|
|
279
|
+
} else {
|
|
280
|
+
const existing = this.store.documentByName(values.name);
|
|
281
|
+
if (existing && (!doc || existing.id !== doc.id)) {
|
|
282
|
+
error = `There is already a document named '${values.name}'`;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (error) {
|
|
286
|
+
return this.sendForm(req, res, doc, values, error);
|
|
287
|
+
}
|
|
288
|
+
if (doc) {
|
|
289
|
+
this.store.updateDocument(doc.id, values, req.user.login);
|
|
290
|
+
this.log.info(`Registry documents: '${values.name}' edited by ${req.user.login}`);
|
|
291
|
+
} else {
|
|
292
|
+
this.store.createDocument(values, req.user.login);
|
|
293
|
+
this.log.info(`Registry documents: '${values.name}' created by ${req.user.login}`);
|
|
294
|
+
}
|
|
295
|
+
res.redirect(`${req.baseUrl || ''}/docs/${encodeURIComponent(values.name)}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
delete(req, res) {
|
|
299
|
+
const doc = this.find(req);
|
|
300
|
+
if (!doc) {
|
|
301
|
+
return this.notFound(req, res);
|
|
302
|
+
}
|
|
303
|
+
this.store.deleteDocument(doc.id, req.user.login);
|
|
304
|
+
this.log.info(`Registry documents: '${doc.name}' deleted by ${req.user.login}`);
|
|
305
|
+
res.redirect(`${req.baseUrl || ''}/docs`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
module.exports = { RegistryDocuments, renderMarkdown, NAME_RE };
|
package/registry/openapi.yaml
CHANGED
|
@@ -34,6 +34,14 @@ info:
|
|
|
34
34
|
|
|
35
35
|
**Parameters are read leniently**: unknown parameters are ignored, and a repeated
|
|
36
36
|
parameter takes its first value.
|
|
37
|
+
|
|
38
|
+
**Authentication.** A registry may be configured to require a token on the JSON API
|
|
39
|
+
(`modules.registry.security`; the HTML pages and this description stay open). Send it
|
|
40
|
+
as `Authorization: Bearer {token}` or `Api-Key: {token}`. Depending on the registry's
|
|
41
|
+
configuration the token is one the administrator set, one issued to a user on the
|
|
42
|
+
registry's pages, or a JWT from the enterprise's OpenID Connect provider. Without a
|
|
43
|
+
valid token the API answers `401` with a `WWW-Authenticate: Bearer` header. An open
|
|
44
|
+
registry ignores any token sent.
|
|
37
45
|
license:
|
|
38
46
|
name: BSD-3-Clause
|
|
39
47
|
url: https://opensource.org/license/bsd-3-clause
|
|
@@ -96,6 +104,7 @@ paths:
|
|
|
96
104
|
text/html:
|
|
97
105
|
schema: { type: string }
|
|
98
106
|
'400': { $ref: '#/components/responses/BadRequest' }
|
|
107
|
+
'401': { $ref: '#/components/responses/Unauthorized' }
|
|
99
108
|
'500': { $ref: '#/components/responses/ServerError' }
|
|
100
109
|
|
|
101
110
|
/resolve:
|
|
@@ -171,8 +180,20 @@ paths:
|
|
|
171
180
|
content:
|
|
172
181
|
application/json:
|
|
173
182
|
schema: { $ref: '#/components/schemas/Error' }
|
|
183
|
+
'401': { $ref: '#/components/responses/Unauthorized' }
|
|
174
184
|
|
|
175
185
|
components:
|
|
186
|
+
securitySchemes:
|
|
187
|
+
bearer:
|
|
188
|
+
type: http
|
|
189
|
+
scheme: bearer
|
|
190
|
+
description: "The token - the registry's own, or an OIDC JWT - as `Authorization: Bearer {token}`"
|
|
191
|
+
apiKey:
|
|
192
|
+
type: apiKey
|
|
193
|
+
in: header
|
|
194
|
+
name: Api-Key
|
|
195
|
+
description: "The same token as `Api-Key: {token}` (what the HL7 Java tooling sends for an `apikey` server in fhir-settings.json)"
|
|
196
|
+
|
|
176
197
|
parameters:
|
|
177
198
|
fhirVersionOptional:
|
|
178
199
|
name: fhirVersion
|
|
@@ -196,6 +217,15 @@ components:
|
|
|
196
217
|
content:
|
|
197
218
|
application/json:
|
|
198
219
|
schema: { $ref: '#/components/schemas/Error' }
|
|
220
|
+
Unauthorized:
|
|
221
|
+
description: "The registry requires a token, and the request carried none or an invalid one. `WWW-Authenticate` names the scheme."
|
|
222
|
+
headers:
|
|
223
|
+
WWW-Authenticate:
|
|
224
|
+
schema: { type: string }
|
|
225
|
+
example: Bearer realm="tx-reg"
|
|
226
|
+
content:
|
|
227
|
+
application/json:
|
|
228
|
+
schema: { $ref: '#/components/schemas/Error' }
|
|
199
229
|
ServerError:
|
|
200
230
|
description: The server failed to process the request
|
|
201
231
|
content:
|