unbound-cli 1.18.0 → 1.20.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 +63 -0
- package/package.json +3 -2
- package/src/auth-pages.js +3 -2
- package/src/auth.js +12 -5
- package/src/commands/login.js +24 -2
- package/src/commands/pack.js +566 -0
- package/src/commands/status.js +9 -1
- package/src/index.js +11 -0
- package/src/lib/pack-export.js +136 -0
- package/src/lib/pack-plan.js +515 -0
- package/src/lib/pack-remote.js +308 -0
- package/src/lib/pack-schema.js +328 -0
- package/src/login-scope.js +85 -0
- package/test/auth-server-leak.test.js +8 -1
- package/test/login-scope-callback.test.js +79 -0
- package/test/login-scope.test.js +51 -0
- package/test/pack-confirm.test.js +27 -0
- package/test/pack-export.test.js +83 -0
- package/test/pack-plan.test.js +201 -0
- package/test/pack-remote.test.js +179 -0
- package/test/pack-schema.test.js +144 -0
package/README.md
CHANGED
|
@@ -54,6 +54,9 @@ Only your API key is needed. Run with `sudo` to let the discovery step scan all
|
|
|
54
54
|
| `unbound login` | Sign in via browser |
|
|
55
55
|
| `unbound login --api-key <key>` | Sign in with an API key (non-interactive) |
|
|
56
56
|
| `unbound login --domain <domain>` | Sign in via a custom domain |
|
|
57
|
+
| `unbound login --role <name>` | Browser sign-in with a token scoped to a console role (`admin`, `analytics-viewer`, `manager`, or a custom role) |
|
|
58
|
+
| `unbound login --permissions <list>` | Browser sign-in with a token scoped per area, e.g. `analytics=view,logs=view` |
|
|
59
|
+
| `unbound login --expires-in-days <n>` | Token lifetime for browser sign-in: 7, 30, 60 or 90 (default 90) |
|
|
57
60
|
| `unbound logout` | Remove the CLI key from the OS keychain |
|
|
58
61
|
| `unbound status` | Show CLI status, role, and connected tools |
|
|
59
62
|
| `unbound doctor` | Diagnose per-tool health (config, hook, env) and API key |
|
|
@@ -212,6 +215,66 @@ unbound policy tool delete <id>
|
|
|
212
215
|
|
|
213
216
|
Before creating any policy, run `unbound policy form-data` to see available user group names, model names, guardrail names, and existing command policies. The CLI accepts names (e.g. `engg`, `claude-3-opus`, `PII`) and resolves them to backend IDs automatically. You can also pass numeric IDs directly.
|
|
214
217
|
|
|
218
|
+
### Policy packs as code (Admin only)
|
|
219
|
+
|
|
220
|
+
Keep policies in versioned YAML files and sync them to your org with `unbound pack`. One file is one **pack**: a named, versioned bundle of one or more policies of kind `tool`, `security`, `model` or `cost`. Every policy in a pack is marked on the server as managed by code, with the pack name and the policy `id` as its identity; its `name` is free text (it defaults to the id). The pack commands match, update and prune only by that identity, so policies made in the console are never read for diffing and never written, whatever they are called.
|
|
221
|
+
|
|
222
|
+
The console shows a **Managed by Code** badge on those policies and only lets people switch them on or off; every other change goes through the pack file. A policy switched off in the console shows up in `pack diff` as `enabled: false -> true (off on the server, on in the pack)`, and `pack apply` turns it back on (set `enabled: false` in the file to keep it off). `unbound pack adopt` brings a console policy into a pack without renaming it. Needs a backend that stores the code identity (ai-gateway-data 0448 or later).
|
|
223
|
+
|
|
224
|
+
Docs: https://docs.getunbound.ai/policies/policy-as-code
|
|
225
|
+
|
|
226
|
+
```yaml
|
|
227
|
+
# policies/k8s-baseline.yaml
|
|
228
|
+
apiVersion: unbound.security/v1
|
|
229
|
+
kind: PolicyPack
|
|
230
|
+
metadata:
|
|
231
|
+
name: k8s-baseline # stored on every policy below as its pack identity
|
|
232
|
+
version: 1.0.0
|
|
233
|
+
defaults:
|
|
234
|
+
scope:
|
|
235
|
+
userGroups: [Engineering]
|
|
236
|
+
policies:
|
|
237
|
+
- id: block-secret-reads
|
|
238
|
+
kind: tool
|
|
239
|
+
type: TERMINAL_COMMAND
|
|
240
|
+
action: BLOCK
|
|
241
|
+
commandFamily: custom
|
|
242
|
+
customMessage: Reading cluster secrets is blocked by the k8s-baseline pack.
|
|
243
|
+
config:
|
|
244
|
+
command: "regex:kubectl\\s+(get|describe)\\s+secrets?"
|
|
245
|
+
- id: github-writes-need-approval
|
|
246
|
+
kind: tool
|
|
247
|
+
type: MCP_TOOL
|
|
248
|
+
action: REQUIRE_SLACK_APPROVAL
|
|
249
|
+
mcp:
|
|
250
|
+
server: github
|
|
251
|
+
actionType: write
|
|
252
|
+
- id: pii-guard
|
|
253
|
+
kind: security
|
|
254
|
+
guardrails:
|
|
255
|
+
- name: PII
|
|
256
|
+
action: BLOCK
|
|
257
|
+
- id: monthly-cap
|
|
258
|
+
kind: cost
|
|
259
|
+
config:
|
|
260
|
+
monthly_budget: 5000
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
unbound pack validate -f policies/ # schema check, no server contact
|
|
265
|
+
unbound pack diff -f policies/k8s-baseline.yaml # what apply would change; exit 1 on drift
|
|
266
|
+
unbound pack apply -f policies/k8s-baseline.yaml --dry-run
|
|
267
|
+
unbound pack apply -f policies/k8s-baseline.yaml
|
|
268
|
+
unbound pack apply -f policies/ --prune # also delete managed policies missing from the files
|
|
269
|
+
unbound pack sync policies/ --yes # folder is the source of truth (apply + prune)
|
|
270
|
+
unbound pack export k8s-baseline -o policies/k8s-baseline.yaml
|
|
271
|
+
unbound pack export --all -o policies/ # one file per pack found in the org
|
|
272
|
+
unbound pack adopt k8s-baseline --from "Block kubectl secrets" -o policies/k8s-baseline.yaml # mark a console policy as managed by the pack; its name stays
|
|
273
|
+
unbound pack delete k8s-baseline --yes # remove every policy the pack manages
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
User groups, guardrails, models, labels and MCP servers are written by name and resolved at apply time; a name that does not resolve fails before anything is written. `config` is passed to the server as-is. Updates send only the fields that differ, and re-running the same file is safe. `diff` and `--dry-run` never write. A removed pack file does not delete its policies (use `pack delete`), because the server keeps no record of applied packs yet.
|
|
277
|
+
|
|
215
278
|
### Users
|
|
216
279
|
|
|
217
280
|
| Command | Description |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unbound-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.0",
|
|
4
4
|
"description": "CLI tool for Unbound - AI Gateway management",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"@iarna/toml": "^2.2.5",
|
|
24
24
|
"@sentry/node": "^10.59.0",
|
|
25
25
|
"commander": "^12.1.0",
|
|
26
|
-
"open": "^10.1.0"
|
|
26
|
+
"open": "^10.1.0",
|
|
27
|
+
"yaml": "^2.9.1"
|
|
27
28
|
},
|
|
28
29
|
"engines": {
|
|
29
30
|
"node": ">=18.0.0"
|
package/src/auth-pages.js
CHANGED
|
@@ -169,12 +169,13 @@ function successPage({ email, orgName } = {}) {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
/** Failure page for a callback that arrived without an api_key. */
|
|
172
|
-
function failurePage() {
|
|
172
|
+
function failurePage(reason) {
|
|
173
|
+
const detail = reason ? `<p>${escapeHtml(reason)}</p>` : '';
|
|
173
174
|
return renderPage({
|
|
174
175
|
title: 'Authentication Failed | Unbound',
|
|
175
176
|
icon: CROSS_ICON,
|
|
176
177
|
heading: 'Authentication Failed',
|
|
177
|
-
body:
|
|
178
|
+
body: `${detail}<p>You can close this tab and try again from the terminal.</p>`,
|
|
178
179
|
});
|
|
179
180
|
}
|
|
180
181
|
|
package/src/auth.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Used by both `login` and `setup` commands.
|
|
4
4
|
*/
|
|
5
5
|
const http = require('http');
|
|
6
|
+
const os = require('os');
|
|
6
7
|
const { URL } = require('url');
|
|
7
8
|
const authPages = require('./auth-pages');
|
|
8
9
|
const config = require('./config');
|
|
@@ -32,7 +33,7 @@ function warnIfNotStored(stored) {
|
|
|
32
33
|
* @param {string} frontendUrl - The frontend URL to open
|
|
33
34
|
* @returns {Promise<{email: string, orgName: string}>}
|
|
34
35
|
*/
|
|
35
|
-
async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
36
|
+
async function loginWithBrowser(frontendUrl, { open: openFn, scope = {} } = {}) {
|
|
36
37
|
const server = http.createServer();
|
|
37
38
|
let callbackResolve;
|
|
38
39
|
let callbackReject;
|
|
@@ -102,10 +103,14 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
|
102
103
|
const apiKey = url.searchParams.get('api_key');
|
|
103
104
|
const email = url.searchParams.get('email');
|
|
104
105
|
const orgName = url.searchParams.get('org');
|
|
106
|
+
// The callback is reachable by any local page, so its text is stripped of control characters before display.
|
|
107
|
+
const reason = (url.searchParams.get('error') || '').replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, 200);
|
|
108
|
+
// `scope` echoes what the dashboard minted; an older dashboard sends none.
|
|
109
|
+
const grantedScope = url.searchParams.get('scope');
|
|
105
110
|
|
|
106
111
|
if (!apiKey) {
|
|
107
|
-
respondAndShutdown(res, 400, authPages.failurePage());
|
|
108
|
-
callbackReject(new Error('No API key received in callback'));
|
|
112
|
+
respondAndShutdown(res, 400, authPages.failurePage(reason));
|
|
113
|
+
callbackReject(new Error(reason || 'No API key received in callback'));
|
|
109
114
|
return;
|
|
110
115
|
}
|
|
111
116
|
|
|
@@ -116,7 +121,7 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
|
116
121
|
config.writeConfig(cfg);
|
|
117
122
|
|
|
118
123
|
respondAndShutdown(res, 200, authPages.successPage({ email, orgName }));
|
|
119
|
-
callbackResolve({ email, orgName });
|
|
124
|
+
callbackResolve(grantedScope ? { email, orgName, scope: grantedScope } : { email, orgName });
|
|
120
125
|
});
|
|
121
126
|
|
|
122
127
|
await new Promise((resolve, reject) => {
|
|
@@ -126,7 +131,9 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
|
126
131
|
|
|
127
132
|
const port = server.address().port;
|
|
128
133
|
const callbackUrl = `http://localhost:${port}/callback`;
|
|
129
|
-
|
|
134
|
+
// `machine` names the login token after this host, so a re-login replaces that token instead of adding one.
|
|
135
|
+
const params = new URLSearchParams({ callback_url: callbackUrl, app_type: 'cli', machine: os.hostname(), ...scope });
|
|
136
|
+
const authUrl = `${frontendUrl}/automations/api-key-callback?${params.toString()}`;
|
|
130
137
|
|
|
131
138
|
const open = openFn || (await import('open')).default;
|
|
132
139
|
|
package/src/commands/login.js
CHANGED
|
@@ -4,6 +4,7 @@ const output = require('../output');
|
|
|
4
4
|
const api = require('../api');
|
|
5
5
|
const { loginWithBrowser, loginWithApiKey, fetchDeviceKey } = require('../auth');
|
|
6
6
|
const { getDeviceSerial } = require('../device-serial');
|
|
7
|
+
const { parseLoginScope, isScoped, describeToken } = require('../login-scope');
|
|
7
8
|
|
|
8
9
|
function register(program) {
|
|
9
10
|
program
|
|
@@ -15,6 +16,9 @@ function register(program) {
|
|
|
15
16
|
.option('--backend-url <url>', 'Tenant backend REST API URL (e.g. https://backend.acme.com). Persisted to config.')
|
|
16
17
|
.addOption(new Option('--base-url <url>', 'Hidden alias of --backend-url, kept for back-compat.').hideHelp())
|
|
17
18
|
.option('--domain <domain>', 'Use a custom domain for login (e.g. custom.example.com)')
|
|
19
|
+
.option('--role <name>', 'Browser login only: scope the token to a console role (admin, analytics-viewer, manager, or a custom role name)')
|
|
20
|
+
.option('--permissions <list>', 'Browser login only: scope the token per area, e.g. analytics=view,logs=view (unlisted areas get none)')
|
|
21
|
+
.option('--expires-in-days <days>', 'Browser login only: token lifetime, one of 7, 30, 60, 90 (default 90)')
|
|
18
22
|
.addHelpText('after', `
|
|
19
23
|
Authentication methods:
|
|
20
24
|
Browser login (default):
|
|
@@ -35,9 +39,17 @@ Options:
|
|
|
35
39
|
--domain sets a custom domain for organizations with self-hosted frontends.
|
|
36
40
|
Bare hostnames (e.g. "api.acme.com") are auto-prefixed with https://.
|
|
37
41
|
|
|
42
|
+
Scoped browser login (--role / --permissions / --expires-in-days):
|
|
43
|
+
By default the login token has your full access for 90 days. Scope it to a
|
|
44
|
+
console role or to specific areas; the dashboard still caps it to your own
|
|
45
|
+
access. Re-running login on this machine replaces its previous token.
|
|
46
|
+
|
|
38
47
|
Examples:
|
|
39
48
|
$ unbound login # Login via default gateway
|
|
40
49
|
$ unbound login --api-key sk-abc123 # Non-interactive login
|
|
50
|
+
$ unbound login --role analytics-viewer # View-only token
|
|
51
|
+
$ unbound login --role "Policy Auditor" --expires-in-days 30
|
|
52
|
+
$ unbound login --permissions analytics=view,logs=view
|
|
41
53
|
$ unbound login --api-key sk-abc123 \\
|
|
42
54
|
--gateway-url https://api.acme.com \\
|
|
43
55
|
--frontend-url https://gateway.acme.com \\
|
|
@@ -60,8 +72,12 @@ Examples:
|
|
|
60
72
|
// wrong tenant.
|
|
61
73
|
const explicitBaseUrl = written.base_url;
|
|
62
74
|
const explicitFrontendUrl = written.frontend_url;
|
|
75
|
+
const scope = parseLoginScope(opts);
|
|
63
76
|
|
|
64
77
|
if (opts.apiKey) {
|
|
78
|
+
if (isScoped(scope)) {
|
|
79
|
+
output.warn('--role, --permissions and --expires-in-days apply to browser login only; the given key is used as is.');
|
|
80
|
+
}
|
|
65
81
|
try {
|
|
66
82
|
await loginWithApiKey(opts.apiKey, { baseUrl: explicitBaseUrl });
|
|
67
83
|
} catch {
|
|
@@ -77,14 +93,20 @@ Examples:
|
|
|
77
93
|
} else {
|
|
78
94
|
frontendUrl = explicitFrontendUrl || config.getFrontendUrl();
|
|
79
95
|
}
|
|
80
|
-
await loginWithBrowser(frontendUrl);
|
|
96
|
+
const result = await loginWithBrowser(frontendUrl, { scope });
|
|
97
|
+
if (isScoped(scope) && !result.scope) {
|
|
98
|
+
output.warn('Dashboard ignored your login flags. Stored a default 90-day full-access token. '
|
|
99
|
+
+ 'Revoke it under Settings › API Tokens if unwanted.');
|
|
100
|
+
}
|
|
81
101
|
// Validate the just-stored key against the explicit backend if one
|
|
82
102
|
// was just persisted; otherwise fall back to the env-var-aware
|
|
83
103
|
// getter. api.get reads the stored key from config internally.
|
|
84
104
|
const deviceSerial = await getDeviceSerial();
|
|
85
|
-
await api.get('/api/v1/users/privileges/', {
|
|
105
|
+
const privileges = await api.get('/api/v1/users/privileges/', {
|
|
86
106
|
baseUrl: explicitBaseUrl, query: { device_serial: deviceSerial },
|
|
87
107
|
});
|
|
108
|
+
const token = describeToken(privileges);
|
|
109
|
+
if (token) output.info(`Token: ${token}`);
|
|
88
110
|
// Replace whatever config.json held (an old CLI login, another org's key) with this login's device key.
|
|
89
111
|
try {
|
|
90
112
|
config.setDeviceKey(await fetchDeviceKey(config.getApiKey(), { baseUrl: explicitBaseUrl }));
|