@elinpf/dsh-ops-access-hub 0.3.0 → 0.4.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/README.md +27 -4
- package/README.zh.md +31 -4
- package/lib/cli.d.ts +3 -0
- package/lib/cli.js +203 -12
- package/lib/index.d.ts +4 -1
- package/lib/index.js +3 -1
- package/lib/server.d.ts +25 -3
- package/lib/server.js +197 -31
- package/lib/store.d.ts +77 -5
- package/lib/store.js +136 -6
- package/lib/tokens.d.ts +110 -0
- package/lib/tokens.js +131 -0
- package/lib/web.d.ts +10 -1
- package/lib/web.js +246 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,9 +7,11 @@ Standalone credential hub for the dsh ops suite — **not a dsh plugin**. A smal
|
|
|
7
7
|
## What it does
|
|
8
8
|
|
|
9
9
|
- **Encrypted-at-rest storage.** The whole dataset is one JSON document (`<data-dir>/hub-data.json.enc`), AES-256-GCM with a random nonce per write, atomic write-temp-then-rename, mode 0600. The master key comes from env `ACCESS_HUB_KEY` (base64/hex) or a key file (default `<data-dir>/hub.key`, generated 0600 on first start). File fields hold their *content*, not paths.
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
10
|
+
- **Token-authenticated HTTP API** (bare `node:http`, default bind `127.0.0.1:3090`), compared with `crypto.timingSafeEqual`, 401/403 distinguished. Two kinds of Bearer token:
|
|
11
|
+
- **Static bootstrap tokens** — one admin (everything), one read (resolving, case writes, the requests list), from CLI flag / env. Always accepted and impossible to revoke: the break-glass path.
|
|
12
|
+
- **Named tokens** (ADR-0009) — issue one per person with `POST /tokens` (or the CLI / web UI): a label, a role and an optional expiry. The plaintext is returned **once**; only its SHA-256 digest is stored. Revoke any single holder with `DELETE /tokens/:id` (terminal, effective immediately) without touching anyone else's credential. Edit a live record in place with `PATCH /tokens/:id` (ADR-0010): rename, change the role, extend or clear the expiry — the secret is untouched, so the holder never has to reconfigure.
|
|
13
|
+
- **Append-only audit log** (`<data-dir>/audit.log`, JSONL): every successful resolve/put/delete with the token role — and, for named tokens, the holder label as `actor`, so multi-person usage is attributable. `token-update` lines also carry `changes` (the field names an edit touched, never values). Never field values.
|
|
14
|
+
- **Single-file web UI** (`GET /`, Chinese): token input (localStorage, shows what it resolves to via `/whoami`), entry list with probe badges, create/edit/delete, a token roster and an audit viewer with the actor column. The page itself holds no secrets. The roster is the fine-grained control surface: a live/expiring/expired/revoked badge per record, search plus status/role filters with counts, local-time created/expires columns, issue with one-time plaintext, in-place edit, revoke.
|
|
13
15
|
|
|
14
16
|
## Usage
|
|
15
17
|
|
|
@@ -19,18 +21,29 @@ dsh-ops-access-hub serve [--port 3090] [--host 127.0.0.1] [--data-dir ~/.dsh-ops
|
|
|
19
21
|
|
|
20
22
|
dsh-ops-access-hub import <access.yaml> \
|
|
21
23
|
(--url <hubUrl> --admin-token <token> | --data-dir <dir>) [--key-file <file>]
|
|
24
|
+
|
|
25
|
+
dsh-ops-access-hub token create --name <name> --role <admin|read> [--expires-at <ISO>] \
|
|
26
|
+
(--url <hubUrl> --admin-token <token> | --data-dir <dir>) [--key-file <file>]
|
|
27
|
+
dsh-ops-access-hub token list (--url <hubUrl> --admin-token <token> | --data-dir <dir>) [--key-file <file>]
|
|
28
|
+
dsh-ops-access-hub token update --id <id> [--name <name>] [--role <admin|read>] \
|
|
29
|
+
[--expires-at <ISO> | --clear-expires] \
|
|
30
|
+
(--url <hubUrl> --admin-token <token> | --data-dir <dir>) [--key-file <file>]
|
|
31
|
+
dsh-ops-access-hub token revoke --id <id> (--url <hubUrl> --admin-token <token> | --data-dir <dir>) [--key-file <file>]
|
|
22
32
|
```
|
|
23
33
|
|
|
24
34
|
Every `serve` flag has an env counterpart (`ACCESS_HUB_PORT`, `ACCESS_HUB_HOST`, `ACCESS_HUB_DATA_DIR`, `ACCESS_HUB_KEY_FILE`, `ACCESS_HUB_ADMIN_TOKEN`, `ACCESS_HUB_READ_TOKEN`). Tokens left unset are generated randomly and printed exactly once on first start.
|
|
25
35
|
|
|
26
36
|
`import` converts an existing ops-access YAML registry: a single-line field value starting with `/`, `~/`, `./` or `../` that points at a readable file is replaced by the file's content (relative paths resolve against the registry file's directory); everything else passes through unchanged. Push into a running hub with `--url`, or write the data file directly with `--data-dir`.
|
|
27
37
|
|
|
38
|
+
`token create` prints the new plaintext exactly once — hand it to its holder out of band; the hub cannot show it again. The static `--admin-token` is the issuing credential (and stays valid as break-glass afterwards). `token update` edits a live record in place and prints only the field names it changed (nothing when the patch matched the current values); `token revoke` is terminal, so a token that should work again gets a new one. `token list` marks each record `active` / `expiring <date> (<N>d left)` / `EXPIRED <date>` / `REVOKED <date>`.
|
|
39
|
+
|
|
28
40
|
## API overview
|
|
29
41
|
|
|
30
42
|
| Endpoint | Auth | Purpose |
|
|
31
43
|
|---|---|---|
|
|
32
44
|
| `GET /health` | none | `{ok:true}` |
|
|
33
45
|
| `GET /` | none | the static web UI |
|
|
46
|
+
| `GET /whoami` | read+ | what the presented token resolved to: `{role,actor,source}` |
|
|
34
47
|
| `GET /entries` | read+ | envelope + tier presence + probe per entry — **never field values** |
|
|
35
48
|
| `GET /entries/:kind/:name/:tier` | read+ | full fields for one tier (audited as `resolve`); 404 when absent |
|
|
36
49
|
| `PUT /entries/:kind/:name/:tier` | admin | upsert `{fields, envelope?, probe?}`; envelope replaces wholesale |
|
|
@@ -41,12 +54,18 @@ Every `serve` flag has an env counterpart (`ACCESS_HUB_PORT`, `ACCESS_HUB_HOST`,
|
|
|
41
54
|
| `POST /cases` / `PUT /cases/:id` | read+ | create / update a case — **deliberate role relaxation**: cases hold no secrets and the agent only carries the read token |
|
|
42
55
|
| `POST /cases/:id/hit` | read+ | bump a case's hit count |
|
|
43
56
|
| `DELETE /cases/:id` | admin | remove a case |
|
|
57
|
+
| `POST /tokens` | admin | issue a named token `{name,role,expiresAt?}` — **plaintext in this response only**; 409 on a duplicate live label |
|
|
58
|
+
| `GET /tokens` | admin | the roster, metadata only — never the digest nor the plaintext |
|
|
59
|
+
| `PATCH /tokens/:id` | admin | edit a live token `{name?,role?,expiresAt?}` — absent keeps, `null`/`''` clears the expiry; the secret is untouched (404 unknown, 409 revoked or label taken, 400 invalid/empty) |
|
|
60
|
+
| `DELETE /tokens/:id` | admin | revoke one named token (terminal; 404 unknown, 409 already revoked) |
|
|
44
61
|
|
|
45
62
|
## Security notes
|
|
46
63
|
|
|
47
64
|
- v1 speaks plain HTTP and binds loopback by default — remote deployments must put the hub behind a TLS-terminating reverse proxy.
|
|
48
65
|
- The hub is a single point of custody: **back up both the data file and the master key.** Without the key the data file is unrecoverable.
|
|
49
66
|
- Keep tokens out of logs and shell history (prefer env injection); the read token suffices for consumers — only writers need the admin token.
|
|
67
|
+
- Named tokens are stored as SHA-256 digests, not secrets: leaking the data file (or a backup) does not hand out working credentials. The static bootstrap tokens are the exception — they live in env/flag config, are the break-glass path, and cannot be revoked through the API.
|
|
68
|
+
- An edit never touches the secret (same digest, same prefix): "wrong label" or "needs three more months" is not a reason to make a holder reconfigure. Making a credential stop working is revocation's job, and revocation stays terminal — a record is never un-revoked.
|
|
50
69
|
|
|
51
70
|
## Testing
|
|
52
71
|
|
|
@@ -54,5 +73,9 @@ Every `serve` flag has an env counterpart (`ACCESS_HUB_PORT`, `ACCESS_HUB_HOST`,
|
|
|
54
73
|
npm run build # tsc → lib/
|
|
55
74
|
npx vitest run # crypto round-trip, key-file generation and permissions,
|
|
56
75
|
# API auth (401/403), CRUD, last-tier cascade delete,
|
|
57
|
-
# probe write-back, audit append, import path→content
|
|
76
|
+
# probe write-back, audit append, import path→content,
|
|
77
|
+
# named tokens (issue / one-time plaintext / role / revoke /
|
|
78
|
+
# expiry / actor attribution), fine-grained token admin
|
|
79
|
+
# (status buckets, in-place PATCH edit, renewal, no-op
|
|
80
|
+
# patches, audit changes), web-shell inline-script compile
|
|
58
81
|
```
|
package/README.zh.md
CHANGED
|
@@ -7,9 +7,11 @@ dsh ops 插件集的独立凭证中心——**不是 dsh 插件**。一个小型
|
|
|
7
7
|
## 功能
|
|
8
8
|
|
|
9
9
|
- **加密落盘**:整个数据集是一个 JSON 文档(`<data-dir>/hub-data.json.enc`),AES-256-GCM、每次写随机 nonce、tmp+rename 原子写、0600。master key 来自环境变量 `ACCESS_HUB_KEY`(base64/hex)或 key 文件(默认 `<data-dir>/hub.key`,首启自动生成,0600)。文件类字段存**内容**而非路径。
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
10
|
+
- **token 认证的 HTTP API**(裸 `node:http`,默认绑 `127.0.0.1:3090`),`crypto.timingSafeEqual` 比较,401/403 区分。两种 Bearer token:
|
|
11
|
+
- **静态 bootstrap token**——admin(全量)与 read(解析、病例写入、申请列表)各一把,来自 CLI flag / env。始终有效、不可吊销:break-glass 路径。
|
|
12
|
+
- **具名 token**(ADR-0009)——用 `POST /tokens`(或 CLI / Web UI)**按人**签发:标签 + 角色 + 可选有效期。明文**只返回一次**,落库只存 SHA-256 摘要;`DELETE /tokens/:id` 按人吊销(终态、立即生效),不影响其他人的凭证。`PATCH /tokens/:id` 就地编辑活记录(ADR-0010):改标签、改角色、延期或清除有效期——**秘密不变**,持有人无需重新配置。
|
|
13
|
+
- **append-only 审计日志**(`<data-dir>/audit.log`,JSONL):每次成功的 resolve/put/delete 连同 token 角色各记一行——具名 token 还记持有人标签 `actor`,多人使用可归属;`token-update` 行另记 `changes`(改动涉及的字段名,不是值)。永不记字段值。
|
|
14
|
+
- **单文件中文 Web UI**(`GET /`):token 输入(localStorage,经 `/whoami` 显示解析出的角色与标签)、带 probe 徽标的条目列表、新建/编辑/删除、token 名册、审计查看(含操作者列)。页面本身不含任何秘密。名册即精细控制面:每行四态徽标(有效 / 即将过期 / 已过期 / 已吊销)、关键字搜索 + 状态/角色筛选 + 计数汇总、本地时区的创建与到期时间、签发与一次性明文展示、就地编辑、吊销。
|
|
13
15
|
|
|
14
16
|
## 用法
|
|
15
17
|
|
|
@@ -19,34 +21,59 @@ dsh-ops-access-hub serve [--port 3090] [--host 127.0.0.1] [--data-dir ~/.dsh-ops
|
|
|
19
21
|
|
|
20
22
|
dsh-ops-access-hub import <access.yaml> \
|
|
21
23
|
(--url <hubUrl> --admin-token <token> | --data-dir <dir>) [--key-file <file>]
|
|
24
|
+
|
|
25
|
+
dsh-ops-access-hub token create --name <name> --role <admin|read> [--expires-at <ISO>] \
|
|
26
|
+
(--url <hubUrl> --admin-token <token> | --data-dir <dir>) [--key-file <file>]
|
|
27
|
+
dsh-ops-access-hub token list (--url <hubUrl> --admin-token <token> | --data-dir <dir>) [--key-file <file>]
|
|
28
|
+
dsh-ops-access-hub token update --id <id> [--name <name>] [--role <admin|read>] \
|
|
29
|
+
[--expires-at <ISO> | --clear-expires] \
|
|
30
|
+
(--url <hubUrl> --admin-token <token> | --data-dir <dir>) [--key-file <file>]
|
|
31
|
+
dsh-ops-access-hub token revoke --id <id> (--url <hubUrl> --admin-token <token> | --data-dir <dir>) [--key-file <file>]
|
|
22
32
|
```
|
|
23
33
|
|
|
24
34
|
每个 `serve` flag 都有环境变量对应(`ACCESS_HUB_PORT`、`ACCESS_HUB_HOST`、`ACCESS_HUB_DATA_DIR`、`ACCESS_HUB_KEY_FILE`、`ACCESS_HUB_ADMIN_TOKEN`、`ACCESS_HUB_READ_TOKEN`)。未配置的 token 首启随机生成并**只打印一次**。
|
|
25
35
|
|
|
26
36
|
`import` 把现有 ops-access YAML 注册表搬进 hub:单行且以 `/`、`~/`、`./`、`../` 开头并指向可读文件的字段值替换为文件内容(相对路径相对注册表文件目录解析),其余原样通过。`--url` 在线推送进运行中的 hub,或 `--data-dir` 离线直写数据文件。
|
|
27
37
|
|
|
38
|
+
`token create` 只打印一次明文——请线下交付持有人,hub 无法再次展示。签发凭证是静态 `--admin-token`(它自己作为 break-glass 继续有效)。`token update` 就地编辑活记录并只打印实际改动的字段名(补丁与现值一致时什么都不打印);`token revoke` 是终态,需要恢复访问就重新签发。`token list` 的状态列区分 `active` / `expiring <时间> (<N>d left)` / `EXPIRED <时间>` / `REVOKED <时间>`。
|
|
39
|
+
|
|
28
40
|
## API 一览
|
|
29
41
|
|
|
30
42
|
| 端点 | 鉴权 | 用途 |
|
|
31
43
|
|---|---|---|
|
|
32
44
|
| `GET /health` | 无 | `{ok:true}` |
|
|
33
45
|
| `GET /` | 无 | 静态 Web UI |
|
|
46
|
+
| `GET /whoami` | read+ | 当前 token 解析结果:`{role,actor,source}` |
|
|
34
47
|
| `GET /entries` | read+ | 每条目的 envelope + tier 存在性 + probe——**永不含字段值** |
|
|
35
48
|
| `GET /entries/:kind/:name/:tier` | read+ | 单个 tier 的完整 fields(记 `resolve` 审计);缺失 404 |
|
|
36
49
|
| `PUT /entries/:kind/:name/:tier` | admin | upsert `{fields, envelope?, probe?}`;envelope 整体替换 |
|
|
37
50
|
| `DELETE /entries/:kind/:name/:tier` | admin | 删除最后一个 tier 时整条删除 |
|
|
38
51
|
| `GET /audit?limit=N` | admin | 最近 N 条审计(默认 100,上限 1000) |
|
|
52
|
+
| `GET /cases` | read+ | 排错病例索引行——只有元数据,无全文 |
|
|
53
|
+
| `GET /cases/:id` | read+ | 单条病例完整记录 |
|
|
54
|
+
| `POST /cases` / `PUT /cases/:id` | read+ | 新建/更新病例——**刻意的角色放宽**:病例不含机密,agent 只持 read token |
|
|
55
|
+
| `POST /cases/:id/hit` | read+ | 病例命中数 +1 |
|
|
56
|
+
| `DELETE /cases/:id` | admin | 删除病例 |
|
|
57
|
+
| `POST /tokens` | admin | 签发具名 token `{name,role,expiresAt?}`——**明文只在这个响应里**;活标签重名 409 |
|
|
58
|
+
| `GET /tokens` | admin | token 名册,只有元数据——永不含摘要与明文 |
|
|
59
|
+
| `PATCH /tokens/:id` | admin | 就地编辑活 token `{name?,role?,expiresAt?}`——缺席=不改、`null`/`''`=清除有效期;**秘密不变**(404 不存在、409 已吊销或标签被占、400 非法值/空补丁) |
|
|
60
|
+
| `DELETE /tokens/:id` | admin | 吊销一个具名 token(终态;404 不存在、409 已吊销) |
|
|
39
61
|
|
|
40
62
|
## 安全注意
|
|
41
63
|
|
|
42
64
|
- v1 是明文 HTTP,默认只绑 loopback——远程部署必须把 hub 放在 TLS 反向代理之后。
|
|
43
65
|
- hub 是单点:**数据文件和 master key 都要备份**。丢了 key,数据文件无法恢复。
|
|
44
66
|
- token 不要进日志和 shell 历史(优先用环境变量注入);消费方只配 read token,admin token 只给写入方。
|
|
67
|
+
- 具名 token 落库的是 SHA-256 摘要而非秘密本身:数据文件(或备份)泄露不会直接交出可用凭证。静态 bootstrap token 是例外——它们活在 env/flag 配置里,是 break-glass 路径,无法通过 API 吊销。
|
|
68
|
+
- 编辑从不碰秘密(摘要、前缀原样):「标签写错」「想再延三个月」不该让持有人重新配置。让一把钥匙失效是吊销的职责,而吊销保持终态——记录不会被"复活"。
|
|
45
69
|
|
|
46
70
|
## 测试
|
|
47
71
|
|
|
48
72
|
```sh
|
|
49
73
|
npm run build # tsc → lib/
|
|
50
74
|
npx vitest run # 加解密往返、key 文件生成与权限、API 鉴权(401/403)、
|
|
51
|
-
# CRUD、最后-tier 连锁删除、probe 回写、审计追加、import
|
|
75
|
+
# CRUD、最后-tier 连锁删除、probe 回写、审计追加、import 路径→内容、
|
|
76
|
+
# 具名 token(签发/一次性明文/角色/吊销/过期/actor 归属)、
|
|
77
|
+
# token 精细管理(状态四分、PATCH 就地编辑、续期、无操作补丁、审计 changes)、
|
|
78
|
+
# Web UI 内联脚本可编译检查
|
|
52
79
|
```
|
package/lib/cli.d.ts
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
* - `import <access.yaml>` — convert an ops-access YAML registry and push it
|
|
10
10
|
* into a running hub (`--url` + `--admin-token`) or straight into a data
|
|
11
11
|
* directory (`--data-dir`, needs the master key).
|
|
12
|
+
* - `token create|list|update|revoke` — issue, list, edit and revoke named
|
|
13
|
+
* tokens (ADR-0009 / ADR-0010), against a running hub (`--url` +
|
|
14
|
+
* `--admin-token`) or directly in a data directory (`--data-dir`).
|
|
12
15
|
* - `--help` — usage.
|
|
13
16
|
*
|
|
14
17
|
* @module
|
package/lib/cli.js
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
* - `import <access.yaml>` — convert an ops-access YAML registry and push it
|
|
10
10
|
* into a running hub (`--url` + `--admin-token`) or straight into a data
|
|
11
11
|
* directory (`--data-dir`, needs the master key).
|
|
12
|
+
* - `token create|list|update|revoke` — issue, list, edit and revoke named
|
|
13
|
+
* tokens (ADR-0009 / ADR-0010), against a running hub (`--url` +
|
|
14
|
+
* `--admin-token`) or directly in a data directory (`--data-dir`).
|
|
12
15
|
* - `--help` — usage.
|
|
13
16
|
*
|
|
14
17
|
* @module
|
|
@@ -19,11 +22,16 @@ import { join } from 'node:path';
|
|
|
19
22
|
import { HubStore } from './store.js';
|
|
20
23
|
import { createHubServer } from './server.js';
|
|
21
24
|
import { applyToStore, importRegistry, pushToHub } from './import.js';
|
|
25
|
+
import { generateToken, hashToken, parseExpiresAt, parseExpiresAtPatch, parseTokenName, parseTokenRole, toTokenView, tokenPrefix, tokenStatus, } from './tokens.js';
|
|
22
26
|
const USAGE = `dsh-ops-access-hub — standalone credential hub for the dsh ops suite
|
|
23
27
|
|
|
24
28
|
Usage:
|
|
25
29
|
dsh-ops-access-hub serve [options]
|
|
26
30
|
dsh-ops-access-hub import <access.yaml> (--url <hubUrl> --admin-token <token> | --data-dir <dir>) [--key-file <file>]
|
|
31
|
+
dsh-ops-access-hub token create --name <name> --role <admin|read> [--expires-at <ISO>] (--url <hubUrl> --admin-token <token> | --data-dir <dir>) [--key-file <file>]
|
|
32
|
+
dsh-ops-access-hub token list (--url <hubUrl> --admin-token <token> | --data-dir <dir>) [--key-file <file>]
|
|
33
|
+
dsh-ops-access-hub token update --id <id> [--name <name>] [--role <admin|read>] [--expires-at <ISO> | --clear-expires] (--url <hubUrl> --admin-token <token> | --data-dir <dir>) [--key-file <file>]
|
|
34
|
+
dsh-ops-access-hub token revoke --id <id> (--url <hubUrl> --admin-token <token> | --data-dir <dir>) [--key-file <file>]
|
|
27
35
|
dsh-ops-access-hub --help
|
|
28
36
|
|
|
29
37
|
serve options (flag / env / default):
|
|
@@ -34,6 +42,15 @@ serve options (flag / env / default):
|
|
|
34
42
|
--admin-token ACCESS_HUB_ADMIN_TOKEN (generated + printed once when unset)
|
|
35
43
|
--read-token ACCESS_HUB_READ_TOKEN (generated + printed once when unset)
|
|
36
44
|
|
|
45
|
+
Named tokens (ADR-0009): issue one per holder with 'token create' and hand the
|
|
46
|
+
printed value over out of band — it is shown once and stored only as a digest.
|
|
47
|
+
--admin-token is the issuing credential (the static bootstrap token).
|
|
48
|
+
'token update' edits a live record in place (label / role / expiry) — the
|
|
49
|
+
secret itself never changes, so the holder keeps working without re-issuing
|
|
50
|
+
(pass --clear-expires to drop an expiry). 'token list' marks each record
|
|
51
|
+
active / expiring / EXPIRED / REVOKED.
|
|
52
|
+
The static admin/read tokens stay valid as break-glass credentials.
|
|
53
|
+
|
|
37
54
|
Master key: env ACCESS_HUB_KEY (base64/hex) wins; otherwise the key file is
|
|
38
55
|
used and generated (0600) on first start.
|
|
39
56
|
`;
|
|
@@ -64,6 +81,188 @@ function expandHome(p) {
|
|
|
64
81
|
function pick(flags, flag, env, fallback) {
|
|
65
82
|
return flags[flag] ?? env ?? fallback;
|
|
66
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Resolve the shared `--url <hubUrl> --admin-token <t>` vs `--data-dir <dir>`
|
|
86
|
+
* target. The two are mutually exclusive; the HTTP form needs the admin token.
|
|
87
|
+
* Errors carry the caller's command name as prefix.
|
|
88
|
+
*/
|
|
89
|
+
function resolveTarget(flags, command) {
|
|
90
|
+
const hubUrl = flags.url;
|
|
91
|
+
const dataDir = flags['data-dir'] ? expandHome(flags['data-dir']) : undefined;
|
|
92
|
+
const adminToken = flags['admin-token'] ?? process.env.ACCESS_HUB_ADMIN_TOKEN;
|
|
93
|
+
if (!hubUrl && !dataDir)
|
|
94
|
+
throw new Error(`${command}: specify either --url <hubUrl> or --data-dir <dir>`);
|
|
95
|
+
if (hubUrl && dataDir)
|
|
96
|
+
throw new Error(`${command}: --url and --data-dir are mutually exclusive`);
|
|
97
|
+
if (hubUrl && !adminToken)
|
|
98
|
+
throw new Error(`${command}: --url mode requires --admin-token (or ACCESS_HUB_ADMIN_TOKEN)`);
|
|
99
|
+
return { hubUrl, adminToken, dataDir };
|
|
100
|
+
}
|
|
101
|
+
/** Open the encrypted store behind a `--data-dir` target. */
|
|
102
|
+
async function openStore(flags, dataDir) {
|
|
103
|
+
const keyFile = expandHome(pick(flags, 'key-file', process.env.ACCESS_HUB_KEY_FILE, join(dataDir, 'hub.key')));
|
|
104
|
+
const store = new HubStore({ dataDir, keyFile, envKey: process.env.ACCESS_HUB_KEY });
|
|
105
|
+
await store.init();
|
|
106
|
+
return store;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* One HTTP call against a running hub with the admin token. Errors never
|
|
110
|
+
* embed the token, and a non-2xx response surfaces the hub's own `error`.
|
|
111
|
+
*/
|
|
112
|
+
async function hubRequest(hubUrl, adminToken, path, init) {
|
|
113
|
+
const res = await fetch(`${hubUrl.replace(/\/$/, '')}${path}`, {
|
|
114
|
+
method: init?.method ?? 'GET',
|
|
115
|
+
headers: {
|
|
116
|
+
authorization: `Bearer ${adminToken}`,
|
|
117
|
+
...(init?.body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
118
|
+
},
|
|
119
|
+
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
|
|
120
|
+
});
|
|
121
|
+
const text = await res.text();
|
|
122
|
+
let body;
|
|
123
|
+
try {
|
|
124
|
+
body = JSON.parse(text);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
body = undefined;
|
|
128
|
+
}
|
|
129
|
+
if (!res.ok) {
|
|
130
|
+
const detail = body && typeof body === 'object' && 'error' in body ? String(body.error) : text.slice(0, 200);
|
|
131
|
+
throw new Error(`hub returned ${res.status}: ${detail}`);
|
|
132
|
+
}
|
|
133
|
+
return body;
|
|
134
|
+
}
|
|
135
|
+
/** Whole days until an ISO expiry, rounded up so a live token never reads "0d left". */
|
|
136
|
+
function daysLeft(expiresAt, now) {
|
|
137
|
+
return Math.ceil((Date.parse(expiresAt) - now.getTime()) / 86_400_000);
|
|
138
|
+
}
|
|
139
|
+
/** One roster line for `token list`; the state column mirrors the web UI's badges (ADR-0010). */
|
|
140
|
+
function formatToken(t, now = new Date()) {
|
|
141
|
+
const status = tokenStatus(t, now);
|
|
142
|
+
let state;
|
|
143
|
+
if (status === 'revoked')
|
|
144
|
+
state = `REVOKED ${t.revokedAt}`;
|
|
145
|
+
else if (status === 'expired')
|
|
146
|
+
state = `EXPIRED ${t.expiresAt}`;
|
|
147
|
+
else if (status === 'expiring')
|
|
148
|
+
state = `expiring ${t.expiresAt} (${daysLeft(t.expiresAt, now)}d left)`;
|
|
149
|
+
else
|
|
150
|
+
state = 'active';
|
|
151
|
+
return `${t.id} ${t.role.padEnd(5)} ${t.prefix}... ${t.name} (created ${t.createdAt} by ${t.createdBy}) ${state}`;
|
|
152
|
+
}
|
|
153
|
+
/** Build the `token update` patch from CLI flags; throws when there is nothing to change. */
|
|
154
|
+
function patchFromFlags(flags) {
|
|
155
|
+
try {
|
|
156
|
+
if (flags['clear-expires'] === 'true' && flags['expires-at'] !== undefined) {
|
|
157
|
+
throw new Error('--expires-at and --clear-expires are mutually exclusive');
|
|
158
|
+
}
|
|
159
|
+
const patch = {};
|
|
160
|
+
if (flags.name !== undefined)
|
|
161
|
+
patch.name = parseTokenName(flags.name);
|
|
162
|
+
if (flags.role !== undefined)
|
|
163
|
+
patch.role = parseTokenRole(flags.role);
|
|
164
|
+
if (flags['clear-expires'] === 'true')
|
|
165
|
+
patch.expiresAt = null;
|
|
166
|
+
else if (flags['expires-at'] !== undefined)
|
|
167
|
+
patch.expiresAt = parseExpiresAtPatch(flags['expires-at']) ?? null;
|
|
168
|
+
if (patch.name === undefined && patch.role === undefined && patch.expiresAt === undefined) {
|
|
169
|
+
throw new Error('nothing to change (use --name, --role, --expires-at or --clear-expires)');
|
|
170
|
+
}
|
|
171
|
+
return patch;
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
throw new Error(`token update: ${err.message}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** Edit one token in an offline store (the caller saves when something changed). */
|
|
178
|
+
function updateTokenInStore(store, id, patch) {
|
|
179
|
+
let updated;
|
|
180
|
+
try {
|
|
181
|
+
updated = store.updateToken(id, patch);
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
throw new Error(`token update: ${err.message}`);
|
|
185
|
+
}
|
|
186
|
+
if (!updated)
|
|
187
|
+
throw new Error(`token update: token ${id} not found`);
|
|
188
|
+
return updated.changes;
|
|
189
|
+
}
|
|
190
|
+
/** `token create|list|update|revoke` — issue and manage named tokens (ADR-0009/0010). */
|
|
191
|
+
async function tokenCmd(args) {
|
|
192
|
+
const { positional, flags } = args;
|
|
193
|
+
const action = positional[1];
|
|
194
|
+
if (action !== 'create' && action !== 'list' && action !== 'update' && action !== 'revoke') {
|
|
195
|
+
throw new Error('token: expected one of create | list | update | revoke');
|
|
196
|
+
}
|
|
197
|
+
const { hubUrl, adminToken, dataDir } = resolveTarget(flags, `token ${action}`);
|
|
198
|
+
const store = dataDir === undefined ? undefined : await openStore(flags, dataDir);
|
|
199
|
+
if (action === 'list') {
|
|
200
|
+
const tokens = store ? store.listTokens().map(toTokenView) : (await hubRequest(hubUrl, adminToken, '/tokens'));
|
|
201
|
+
if (tokens.length === 0)
|
|
202
|
+
console.log('no named tokens issued');
|
|
203
|
+
else
|
|
204
|
+
for (const t of tokens)
|
|
205
|
+
console.log(formatToken(t));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (action === 'create') {
|
|
209
|
+
const name = parseTokenName(flags.name ?? '');
|
|
210
|
+
const role = parseTokenRole(flags.role ?? '');
|
|
211
|
+
const expiresAt = parseExpiresAt(flags['expires-at']);
|
|
212
|
+
const entity = store
|
|
213
|
+
? createTokenInStore(store, { name, role, expiresAt })
|
|
214
|
+
: (await hubRequest(hubUrl, adminToken, '/tokens', {
|
|
215
|
+
method: 'POST',
|
|
216
|
+
body: { name, role, ...(expiresAt !== undefined ? { expiresAt } : {}) },
|
|
217
|
+
}));
|
|
218
|
+
if (store)
|
|
219
|
+
await store.save();
|
|
220
|
+
console.log(`issued ${role} token '${name}' (id ${entity.id}) — hand it over out of band; it will not be shown again:`);
|
|
221
|
+
console.log(` ${entity.token}`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const id = flags.id;
|
|
225
|
+
if (!id)
|
|
226
|
+
throw new Error(`token ${action}: missing --id <id>`);
|
|
227
|
+
if (action === 'update') {
|
|
228
|
+
const patch = patchFromFlags(flags);
|
|
229
|
+
const changes = store
|
|
230
|
+
? updateTokenInStore(store, id, patch)
|
|
231
|
+
: (await hubRequest(hubUrl, adminToken, `/tokens/${encodeURIComponent(id)}`, { method: 'PATCH', body: patch })).changes;
|
|
232
|
+
// A no-op patch must not rewrite (and re-encrypt) the whole document.
|
|
233
|
+
if (store && changes.length > 0)
|
|
234
|
+
await store.save();
|
|
235
|
+
console.log(changes.length === 0 ? `token ${id}: already up to date` : `updated token ${id}: ${changes.join(', ')}`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (store) {
|
|
239
|
+
const token = store.getToken(id);
|
|
240
|
+
if (!token)
|
|
241
|
+
throw new Error(`token revoke: token ${id} not found`);
|
|
242
|
+
if (!store.revokeToken(id))
|
|
243
|
+
throw new Error(`token revoke: token ${id} is already revoked`);
|
|
244
|
+
await store.save();
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
await hubRequest(hubUrl, adminToken, `/tokens/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
248
|
+
}
|
|
249
|
+
console.log(`revoked token ${id}`);
|
|
250
|
+
}
|
|
251
|
+
/** Mint + record one token in an offline store (the caller saves). */
|
|
252
|
+
function createTokenInStore(store, input) {
|
|
253
|
+
if (store.findTokenByName(input.name))
|
|
254
|
+
throw new Error(`token create: name '${input.name}' is already in use`);
|
|
255
|
+
const token = generateToken();
|
|
256
|
+
const issued = store.putToken({
|
|
257
|
+
name: input.name,
|
|
258
|
+
role: input.role,
|
|
259
|
+
hash: hashToken(token),
|
|
260
|
+
prefix: tokenPrefix(token),
|
|
261
|
+
createdBy: 'cli',
|
|
262
|
+
...(input.expiresAt !== undefined ? { expiresAt: input.expiresAt } : {}),
|
|
263
|
+
});
|
|
264
|
+
return { id: issued.id, token };
|
|
265
|
+
}
|
|
67
266
|
async function serve(args) {
|
|
68
267
|
const { flags } = args;
|
|
69
268
|
const port = Number.parseInt(pick(flags, 'port', process.env.ACCESS_HUB_PORT, '3090'), 10);
|
|
@@ -100,23 +299,13 @@ async function importCmd(args) {
|
|
|
100
299
|
const registryFile = positional[1];
|
|
101
300
|
if (!registryFile)
|
|
102
301
|
throw new Error('import: missing <access.yaml> argument');
|
|
103
|
-
const hubUrl = flags
|
|
104
|
-
const adminToken = flags['admin-token'] ?? process.env.ACCESS_HUB_ADMIN_TOKEN;
|
|
105
|
-
const dataDir = flags['data-dir'] ? expandHome(flags['data-dir']) : undefined;
|
|
106
|
-
if (!hubUrl && !dataDir)
|
|
107
|
-
throw new Error('import: specify either --url <hubUrl> or --data-dir <dir>');
|
|
108
|
-
if (hubUrl && dataDir)
|
|
109
|
-
throw new Error('import: --url and --data-dir are mutually exclusive');
|
|
110
|
-
if (hubUrl && !adminToken)
|
|
111
|
-
throw new Error('import: --url mode requires --admin-token (or ACCESS_HUB_ADMIN_TOKEN)');
|
|
302
|
+
const { hubUrl, adminToken, dataDir } = resolveTarget(flags, 'import');
|
|
112
303
|
const { entries, stats } = await importRegistry(expandHome(registryFile));
|
|
113
304
|
if (hubUrl) {
|
|
114
305
|
await pushToHub(hubUrl, adminToken, entries);
|
|
115
306
|
}
|
|
116
307
|
else {
|
|
117
|
-
const
|
|
118
|
-
const store = new HubStore({ dataDir: dataDir, keyFile, envKey: process.env.ACCESS_HUB_KEY });
|
|
119
|
-
await store.init();
|
|
308
|
+
const store = await openStore(flags, dataDir);
|
|
120
309
|
applyToStore(store, entries);
|
|
121
310
|
await store.save();
|
|
122
311
|
}
|
|
@@ -129,6 +318,8 @@ async function main() {
|
|
|
129
318
|
return serve(args);
|
|
130
319
|
if (command === 'import')
|
|
131
320
|
return importCmd(args);
|
|
321
|
+
if (command === 'token')
|
|
322
|
+
return tokenCmd(args);
|
|
132
323
|
if (command === 'help' || args.flags.help === 'true' || args.flags.h === 'true' || command === undefined) {
|
|
133
324
|
console.log(USAGE);
|
|
134
325
|
return;
|
package/lib/index.d.ts
CHANGED
|
@@ -5,14 +5,17 @@
|
|
|
5
5
|
*
|
|
6
6
|
* - encrypted-at-rest single-document store (AES-256-GCM), see `./store.js`;
|
|
7
7
|
* - token-authenticated REST API + minimal web UI, see `./server.js`;
|
|
8
|
+
* - named, revocable tokens with roles and audit attribution, see `./tokens.js`;
|
|
8
9
|
* - YAML registry importer, see `./import.js`;
|
|
9
|
-
* - `dsh-ops-access-hub` bin (`serve` / `import`), see `./cli.js`.
|
|
10
|
+
* - `dsh-ops-access-hub` bin (`serve` / `import` / `token`), see `./cli.js`.
|
|
10
11
|
*
|
|
11
12
|
* @module @elinpf/dsh-ops-access-hub
|
|
12
13
|
*/
|
|
13
14
|
export { MASTER_KEY_BYTES, generateMasterKey, parseMasterKey, loadMasterKey, encryptDoc, decryptDoc } from './crypto.js';
|
|
14
15
|
export { HubStore, MAX_CASES } from './store.js';
|
|
15
16
|
export type { TierName, ProbeState, EntryEnvelope, TierData, HubEntry, AuditRecord, HubStoreOptions, CaseRecord, CaseInput } from './store.js';
|
|
17
|
+
export { MAX_TOKENS, TOKEN_NAME_PATTERN, TOKEN_PREFIX_CHARS, TOKEN_ROLES, generateToken, hashEqual, hashToken, isTokenActive, parseExpiresAt, parseTokenName, parseTokenRole, toTokenView, tokenPrefix, } from './tokens.js';
|
|
18
|
+
export type { HubToken, TokenRole, TokenView } from './tokens.js';
|
|
16
19
|
export { createHubServer, NAME_PATTERN } from './server.js';
|
|
17
20
|
export type { HubServerOptions } from './server.js';
|
|
18
21
|
export { importRegistry, pushToHub, applyToStore } from './import.js';
|
package/lib/index.js
CHANGED
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
*
|
|
6
6
|
* - encrypted-at-rest single-document store (AES-256-GCM), see `./store.js`;
|
|
7
7
|
* - token-authenticated REST API + minimal web UI, see `./server.js`;
|
|
8
|
+
* - named, revocable tokens with roles and audit attribution, see `./tokens.js`;
|
|
8
9
|
* - YAML registry importer, see `./import.js`;
|
|
9
|
-
* - `dsh-ops-access-hub` bin (`serve` / `import`), see `./cli.js`.
|
|
10
|
+
* - `dsh-ops-access-hub` bin (`serve` / `import` / `token`), see `./cli.js`.
|
|
10
11
|
*
|
|
11
12
|
* @module @elinpf/dsh-ops-access-hub
|
|
12
13
|
*/
|
|
13
14
|
export { MASTER_KEY_BYTES, generateMasterKey, parseMasterKey, loadMasterKey, encryptDoc, decryptDoc } from './crypto.js';
|
|
14
15
|
export { HubStore, MAX_CASES } from './store.js';
|
|
16
|
+
export { MAX_TOKENS, TOKEN_NAME_PATTERN, TOKEN_PREFIX_CHARS, TOKEN_ROLES, generateToken, hashEqual, hashToken, isTokenActive, parseExpiresAt, parseTokenName, parseTokenRole, toTokenView, tokenPrefix, } from './tokens.js';
|
|
15
17
|
export { createHubServer, NAME_PATTERN } from './server.js';
|
|
16
18
|
export { importRegistry, pushToHub, applyToStore } from './import.js';
|
|
17
19
|
export { WEB_UI_HTML } from './web.js';
|
package/lib/server.d.ts
CHANGED
|
@@ -28,10 +28,26 @@
|
|
|
28
28
|
* read token)
|
|
29
29
|
* - `POST /cases/:id/hit` → bump a case's hit count (read+)
|
|
30
30
|
* - `DELETE /cases/:id` → remove a case (admin)
|
|
31
|
+
* - `GET /whoami` → `{ok,role,actor,source}` for the presented
|
|
32
|
+
* token (read+)
|
|
33
|
+
* - `POST /tokens` → issue a named token, body `{name,role,expiresAt?}`;
|
|
34
|
+
* the plaintext is in this response only (admin)
|
|
35
|
+
* - `GET /tokens` → issued-token roster, metadata only — never
|
|
36
|
+
* the digest or the plaintext (admin)
|
|
37
|
+
* - `PATCH /tokens/:id` → edit a live token's `{name?,role?,expiresAt?}`
|
|
38
|
+
* (`null`/`''` clears the expiry); 404 unknown,
|
|
39
|
+
* 409 revoked or label taken (admin)
|
|
40
|
+
* - `DELETE /tokens/:id` → revoke a named token; 404 unknown, 409 already
|
|
41
|
+
* revoked (admin)
|
|
31
42
|
*
|
|
32
|
-
* Auth:
|
|
33
|
-
*
|
|
34
|
-
*
|
|
43
|
+
* Auth: `Authorization: Bearer <token>`, compared with `crypto.timingSafeEqual`.
|
|
44
|
+
* Two roles — admin (everything) and read (resolving, case writes, the
|
|
45
|
+
* requests list). A token is either one of the two static bootstrap tokens
|
|
46
|
+
* (CLI flag / env, always accepted, the break-glass path) or a named token
|
|
47
|
+
* issued through `POST /tokens` (ADR-0009): independently revocable,
|
|
48
|
+
* optionally expiring, and recorded as the audit `actor`. Named tokens are
|
|
49
|
+
* stored as SHA-256 digests only. Every error response is JSON
|
|
50
|
+
* `{ok:false,error}` and `error` never contains field values.
|
|
35
51
|
*
|
|
36
52
|
* @module
|
|
37
53
|
*/
|
|
@@ -41,7 +57,13 @@ import { NAME_PATTERN } from './store.js';
|
|
|
41
57
|
export { NAME_PATTERN };
|
|
42
58
|
export interface HubServerOptions {
|
|
43
59
|
store: HubStore;
|
|
60
|
+
/**
|
|
61
|
+
* Static bootstrap admin token (flag/env). Always accepted — the
|
|
62
|
+
* break-glass path that keeps the hub reachable after every named token
|
|
63
|
+
* has been revoked, and the credential from before ADR-0009 existed.
|
|
64
|
+
*/
|
|
44
65
|
adminToken: string;
|
|
66
|
+
/** Static bootstrap read token (flag/env); same bootstrap role as `adminToken`. */
|
|
45
67
|
readToken: string;
|
|
46
68
|
}
|
|
47
69
|
export declare function createHubServer(opts: HubServerOptions): Server;
|