@faable/faable 1.31.0 → 1.32.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/dist/api/auth_admin.js +67 -0
- package/dist/commands/auth/actions/create.js +62 -0
- package/dist/commands/auth/actions/get.js +40 -0
- package/dist/commands/auth/actions/index.js +19 -0
- package/dist/commands/auth/actions/list.js +43 -0
- package/dist/commands/auth/actions/rm.js +45 -0
- package/dist/commands/auth/clients/create.js +56 -0
- package/dist/commands/auth/clients/get.js +46 -0
- package/dist/commands/auth/clients/index.js +19 -0
- package/dist/commands/auth/clients/list.js +41 -0
- package/dist/commands/auth/clients/resolve.js +21 -0
- package/dist/commands/auth/clients/rm.js +45 -0
- package/dist/commands/auth/index.js +23 -0
- package/dist/commands/auth/logs/get.js +48 -0
- package/dist/commands/auth/logs/index.js +15 -0
- package/dist/commands/auth/logs/list.js +90 -0
- package/dist/commands/auth/options.js +34 -0
- package/dist/commands/auth/paging.js +9 -0
- package/dist/commands/auth/query.js +36 -0
- package/dist/commands/auth/render.js +48 -0
- package/dist/commands/auth/users/get.js +42 -0
- package/dist/commands/auth/users/ids.js +45 -0
- package/dist/commands/auth/users/index.js +17 -0
- package/dist/commands/auth/users/list.js +55 -0
- package/dist/commands/auth/users/suspend.js +87 -0
- package/dist/index.js +6 -0
- package/package.json +2 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { FaableAuthApi } from '@faable/auth-sdk';
|
|
2
|
+
import { CredentialsStore } from '../lib/CredentialsStore.js';
|
|
3
|
+
import { log } from '../log.js';
|
|
4
|
+
import { loadLiveCredentials } from './session.js';
|
|
5
|
+
|
|
6
|
+
// Default tenant host. `faable auth` is customer-facing: a customer targets
|
|
7
|
+
// their own tenant with --auth-url https://<account>.auth.faable.link (or
|
|
8
|
+
// FAABLE_AUTH_URL); the default points at the Faable tenant.
|
|
9
|
+
const DEFAULT_AUTH_URL = 'https://faable.auth.faable.link';
|
|
10
|
+
// Build a management-API client for the Auth server reusing the CLI session
|
|
11
|
+
// (FAABLE_TOKEN → `faable login` credentials, auto-refreshed). The bearer is
|
|
12
|
+
// sent as-is and the SERVER decides whether it may manage the target tenant
|
|
13
|
+
// (today MGMT_AUTH_ENFORCE is authz dry-run; when enforced, tokens without the
|
|
14
|
+
// management audience/scopes for the tenant will get 403 — see
|
|
15
|
+
// auth/docs/management-api-m2m-token.md for the M2M path).
|
|
16
|
+
const requireAuthAdmin = async (opts = {}) => {
|
|
17
|
+
let token = process.env.FAABLE_TOKEN;
|
|
18
|
+
if (!token) {
|
|
19
|
+
const store = new CredentialsStore();
|
|
20
|
+
const config = await loadLiveCredentials(store);
|
|
21
|
+
if (config?.apikey && !config.token) {
|
|
22
|
+
// Deploy API keys are not Auth management credentials.
|
|
23
|
+
log.error("❌ You are logged in with an API key, but `faable auth` needs a browser session. Run 'faable login' (without --apikey) first.");
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
token = config?.token;
|
|
27
|
+
}
|
|
28
|
+
if (!token) {
|
|
29
|
+
log.error("❌ Not logged in. Run 'faable login' first.");
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
const domain = opts.authUrl || process.env.FAABLE_AUTH_URL || DEFAULT_AUTH_URL;
|
|
33
|
+
const account = opts.account || process.env.FAABLE_AUTH_ACCOUNT;
|
|
34
|
+
return new FaableAuthApi({
|
|
35
|
+
domain,
|
|
36
|
+
...(account ? { headers: { account_id: account } } : {}),
|
|
37
|
+
// Static bearer: passing no `auth` keeps sdk-base from attaching any token
|
|
38
|
+
// strategy, so this header is used verbatim on every request.
|
|
39
|
+
fetcher: { headers: { Authorization: `Bearer ${token}` } }
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
// Translate raw management-API failures into actionable CLI errors. Everything
|
|
43
|
+
// else is rethrown untouched (FaableApiError messages already carry
|
|
44
|
+
// status+url) and lands in the global yargs .fail() handler.
|
|
45
|
+
const hintAuthError = (e) => {
|
|
46
|
+
const status = e?.response?.status;
|
|
47
|
+
if (status === 401) {
|
|
48
|
+
throw new Error("Unauthorized (401) by the Auth management API. Your session may have expired — run 'faable login' and retry.");
|
|
49
|
+
}
|
|
50
|
+
if (status === 403) {
|
|
51
|
+
throw new Error('Forbidden (403): your session is not allowed to manage this tenant. Check --auth-url/--account, or use credentials with management access for it.');
|
|
52
|
+
}
|
|
53
|
+
throw e;
|
|
54
|
+
};
|
|
55
|
+
// Wrap a yargs handler so management-API errors surface with the hints above.
|
|
56
|
+
const withAuthHints = (handler) => {
|
|
57
|
+
return async (args) => {
|
|
58
|
+
try {
|
|
59
|
+
await handler(args);
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
hintAuthError(e);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export { hintAuthError, requireAuthAdmin, withAuthHints };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
|
|
3
|
+
import { log } from '../../../log.js';
|
|
4
|
+
import { json_option, tenant_options } from '../options.js';
|
|
5
|
+
import { print_json } from '../render.js';
|
|
6
|
+
|
|
7
|
+
const actions_create = {
|
|
8
|
+
command: 'create',
|
|
9
|
+
describe: 'Create an action',
|
|
10
|
+
builder: yargs => json_option(tenant_options(yargs))
|
|
11
|
+
.option('name', {
|
|
12
|
+
alias: 'n',
|
|
13
|
+
type: 'string',
|
|
14
|
+
demandOption: true,
|
|
15
|
+
description: 'Action name (max 200 chars)'
|
|
16
|
+
})
|
|
17
|
+
.option('trigger', {
|
|
18
|
+
alias: 't',
|
|
19
|
+
type: 'string',
|
|
20
|
+
choices: ['post-login', 'continue'],
|
|
21
|
+
demandOption: true,
|
|
22
|
+
description: 'Trigger point'
|
|
23
|
+
})
|
|
24
|
+
.option('code-file', {
|
|
25
|
+
alias: 'f',
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'Path to a JS file with the action code'
|
|
28
|
+
})
|
|
29
|
+
.option('disabled', {
|
|
30
|
+
type: 'boolean',
|
|
31
|
+
default: false,
|
|
32
|
+
description: 'Create the action disabled'
|
|
33
|
+
})
|
|
34
|
+
.option('order', {
|
|
35
|
+
type: 'number',
|
|
36
|
+
description: 'Execution order (lower runs first, default 0)'
|
|
37
|
+
})
|
|
38
|
+
.example('$0 auth actions create -n add-claims -t post-login -f ./claims.js', 'Create a post-login action from a file')
|
|
39
|
+
.showHelpOnFail(false),
|
|
40
|
+
handler: withAuthHints(async (args) => {
|
|
41
|
+
let code;
|
|
42
|
+
if (args.codeFile) {
|
|
43
|
+
if (!(await fs.pathExists(args.codeFile))) {
|
|
44
|
+
throw new Error(`Code file not found: ${args.codeFile}`);
|
|
45
|
+
}
|
|
46
|
+
code = await fs.readFile(args.codeFile, 'utf8');
|
|
47
|
+
}
|
|
48
|
+
const api = await requireAuthAdmin(args);
|
|
49
|
+
const action = await api.actionCreate({
|
|
50
|
+
name: args.name,
|
|
51
|
+
trigger: args.trigger,
|
|
52
|
+
...(code !== undefined ? { code } : {}),
|
|
53
|
+
...(args.disabled ? { enabled: false } : {}),
|
|
54
|
+
...(args.order !== undefined ? { order: args.order } : {})
|
|
55
|
+
});
|
|
56
|
+
if (args.json)
|
|
57
|
+
return print_json(action);
|
|
58
|
+
log.info(`✅ Created action ${action.id} (${action.name}, trigger ${action.trigger})`);
|
|
59
|
+
})
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export { actions_create };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
|
|
2
|
+
import { log } from '../../../log.js';
|
|
3
|
+
import { json_option, tenant_options } from '../options.js';
|
|
4
|
+
import { print_json, yes_no } from '../render.js';
|
|
5
|
+
|
|
6
|
+
const actions_get = {
|
|
7
|
+
command: 'get <action_id>',
|
|
8
|
+
describe: 'Show an action (use --code to print its source)',
|
|
9
|
+
builder: yargs => json_option(tenant_options(yargs))
|
|
10
|
+
.positional('action_id', {
|
|
11
|
+
type: 'string',
|
|
12
|
+
demandOption: true,
|
|
13
|
+
description: 'Action identifier'
|
|
14
|
+
})
|
|
15
|
+
.option('code', {
|
|
16
|
+
type: 'boolean',
|
|
17
|
+
default: false,
|
|
18
|
+
description: 'Print the action source code'
|
|
19
|
+
})
|
|
20
|
+
.showHelpOnFail(false),
|
|
21
|
+
handler: withAuthHints(async (args) => {
|
|
22
|
+
const api = await requireAuthAdmin(args);
|
|
23
|
+
const action = await api.actionGet(args.action_id);
|
|
24
|
+
if (args.json)
|
|
25
|
+
return print_json(action);
|
|
26
|
+
log.info(`⚙️ ${action.id}`);
|
|
27
|
+
log.info(` Name: ${action.name ?? '-'}`);
|
|
28
|
+
log.info(` Trigger: ${action.trigger ?? '-'}`);
|
|
29
|
+
log.info(` Enabled: ${yes_no(action.enabled)}`);
|
|
30
|
+
log.info(` Order: ${action.order ?? 0}`);
|
|
31
|
+
log.info(` Created: ${action.createdAt ?? '-'}`);
|
|
32
|
+
if (args.code) {
|
|
33
|
+
log.info(' Code:');
|
|
34
|
+
// Raw to stdout so it can be piped to a file untouched.
|
|
35
|
+
process.stdout.write((action.code ?? '') + '\n');
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export { actions_get };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { actions_create } from './create.js';
|
|
2
|
+
import { actions_get } from './get.js';
|
|
3
|
+
import { actions_list } from './list.js';
|
|
4
|
+
import { actions_rm } from './rm.js';
|
|
5
|
+
|
|
6
|
+
const actions = {
|
|
7
|
+
command: 'actions',
|
|
8
|
+
describe: 'Manage auth actions (login-flow hooks)',
|
|
9
|
+
builder: yargs => yargs
|
|
10
|
+
.command(actions_list)
|
|
11
|
+
.command(actions_get)
|
|
12
|
+
.command(actions_create)
|
|
13
|
+
.command(actions_rm)
|
|
14
|
+
.demandCommand(1)
|
|
15
|
+
.showHelpOnFail(false),
|
|
16
|
+
handler: () => { }
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export { actions };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
|
|
2
|
+
import { log } from '../../../log.js';
|
|
3
|
+
import { json_option, list_options, tenant_options } from '../options.js';
|
|
4
|
+
import { fetch_items } from '../paging.js';
|
|
5
|
+
import { print_json, yes_no, when, table_lines } from '../render.js';
|
|
6
|
+
|
|
7
|
+
const actions_list = {
|
|
8
|
+
command: 'list',
|
|
9
|
+
describe: 'List actions',
|
|
10
|
+
builder: yargs => json_option(list_options(tenant_options(yargs)))
|
|
11
|
+
.option('query', {
|
|
12
|
+
type: 'string',
|
|
13
|
+
description: 'FaableQL filter, e.g. "trigger:post-login"'
|
|
14
|
+
})
|
|
15
|
+
.showHelpOnFail(false),
|
|
16
|
+
handler: withAuthHints(async (args) => {
|
|
17
|
+
const api = await requireAuthAdmin(args);
|
|
18
|
+
const { items, more } = await fetch_items(api.actionList({ query: args.query, pageSize: args.limit }), args.all);
|
|
19
|
+
if (args.json)
|
|
20
|
+
return print_json(items);
|
|
21
|
+
if (items.length === 0) {
|
|
22
|
+
log.info('📭 No actions.');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
log.info(`⚙️ ${items.length} action(s):`);
|
|
26
|
+
const rows = items.map(a => [
|
|
27
|
+
a.id ?? '-',
|
|
28
|
+
a.name ?? '-',
|
|
29
|
+
a.trigger ?? '-',
|
|
30
|
+
yes_no(a.enabled),
|
|
31
|
+
String(a.order ?? 0),
|
|
32
|
+
when(a.createdAt)
|
|
33
|
+
]);
|
|
34
|
+
for (const line of table_lines(['ID', 'NAME', 'TRIGGER', 'ENABLED', 'ORDER', 'CREATED'], rows)) {
|
|
35
|
+
log.info(` ${line}`);
|
|
36
|
+
}
|
|
37
|
+
if (more) {
|
|
38
|
+
log.info('… more results available: raise --limit or use --all.');
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export { actions_list };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import prompts from 'prompts';
|
|
2
|
+
import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
|
|
3
|
+
import { log } from '../../../log.js';
|
|
4
|
+
import { tenant_options } from '../options.js';
|
|
5
|
+
|
|
6
|
+
const actions_rm = {
|
|
7
|
+
command: 'rm <action_id>',
|
|
8
|
+
describe: 'Delete an action',
|
|
9
|
+
builder: yargs => tenant_options(yargs)
|
|
10
|
+
.positional('action_id', {
|
|
11
|
+
type: 'string',
|
|
12
|
+
demandOption: true,
|
|
13
|
+
description: 'Action identifier'
|
|
14
|
+
})
|
|
15
|
+
.option('yes', {
|
|
16
|
+
alias: 'y',
|
|
17
|
+
type: 'boolean',
|
|
18
|
+
default: false,
|
|
19
|
+
description: 'Skip the confirmation prompt'
|
|
20
|
+
})
|
|
21
|
+
.showHelpOnFail(false),
|
|
22
|
+
handler: withAuthHints(async (args) => {
|
|
23
|
+
const api = await requireAuthAdmin(args);
|
|
24
|
+
// Fetch first: shows WHAT will be deleted and 404s before the prompt.
|
|
25
|
+
const action = await api.actionGet(args.action_id);
|
|
26
|
+
if (!args.yes) {
|
|
27
|
+
const { confirm } = await prompts({
|
|
28
|
+
type: 'toggle',
|
|
29
|
+
name: 'confirm',
|
|
30
|
+
message: `Delete action "${action.name}" (${action.id}, trigger ${action.trigger})?`,
|
|
31
|
+
initial: false,
|
|
32
|
+
active: 'yes',
|
|
33
|
+
inactive: 'no'
|
|
34
|
+
});
|
|
35
|
+
if (!confirm) {
|
|
36
|
+
log.info('Cancelled.');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
await api.actionDelete(args.action_id);
|
|
41
|
+
log.info(`🗑️ Deleted action ${args.action_id}.`);
|
|
42
|
+
})
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export { actions_rm };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
|
|
2
|
+
import { log } from '../../../log.js';
|
|
3
|
+
import { json_option, tenant_options } from '../options.js';
|
|
4
|
+
import { print_json } from '../render.js';
|
|
5
|
+
|
|
6
|
+
const clients_create = {
|
|
7
|
+
command: 'create',
|
|
8
|
+
describe: 'Create an OAuth client',
|
|
9
|
+
builder: yargs => json_option(tenant_options(yargs))
|
|
10
|
+
.option('name', {
|
|
11
|
+
alias: 'n',
|
|
12
|
+
type: 'string',
|
|
13
|
+
demandOption: true,
|
|
14
|
+
description: 'Client name'
|
|
15
|
+
})
|
|
16
|
+
.option('description', {
|
|
17
|
+
alias: 'd',
|
|
18
|
+
type: 'string',
|
|
19
|
+
description: 'Client description'
|
|
20
|
+
})
|
|
21
|
+
.option('callback', {
|
|
22
|
+
type: 'string',
|
|
23
|
+
array: true,
|
|
24
|
+
description: 'Allowed callback (redirect) URL — repeatable'
|
|
25
|
+
})
|
|
26
|
+
.option('logout-url', {
|
|
27
|
+
type: 'string',
|
|
28
|
+
array: true,
|
|
29
|
+
description: 'Allowed post-logout URL — repeatable'
|
|
30
|
+
})
|
|
31
|
+
.option('web-origin', {
|
|
32
|
+
type: 'string',
|
|
33
|
+
array: true,
|
|
34
|
+
description: 'Allowed web origin (CORS) — repeatable'
|
|
35
|
+
})
|
|
36
|
+
.example('$0 auth clients create -n my-app --callback https://app.example.com/callback', 'Create a client with one redirect URL')
|
|
37
|
+
.showHelpOnFail(false),
|
|
38
|
+
handler: withAuthHints(async (args) => {
|
|
39
|
+
const api = await requireAuthAdmin(args);
|
|
40
|
+
const client = await api.clientCreate({
|
|
41
|
+
name: args.name,
|
|
42
|
+
...(args.description ? { description: args.description } : {}),
|
|
43
|
+
...(args.callback?.length ? { callbacks: args.callback } : {}),
|
|
44
|
+
...(args.logoutUrl?.length ? { logout_urls: args.logoutUrl } : {}),
|
|
45
|
+
...(args.webOrigin?.length ? { web_origins: args.webOrigin } : {})
|
|
46
|
+
});
|
|
47
|
+
if (args.json)
|
|
48
|
+
return print_json(client);
|
|
49
|
+
log.info(`✅ Created client "${client.name}" (${client.id})`);
|
|
50
|
+
log.info(` Client ID: ${client.client_id}`);
|
|
51
|
+
log.info(` Client secret: ${client.client_secret}`);
|
|
52
|
+
log.info(' ⚠️ Store the secret now — treat it like a password.');
|
|
53
|
+
})
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export { clients_create };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
|
|
2
|
+
import { log } from '../../../log.js';
|
|
3
|
+
import { json_option, tenant_options } from '../options.js';
|
|
4
|
+
import { print_json } from '../render.js';
|
|
5
|
+
import { resolve_client } from './resolve.js';
|
|
6
|
+
|
|
7
|
+
const clients_get = {
|
|
8
|
+
command: 'get <client_id>',
|
|
9
|
+
describe: 'Show an OAuth client',
|
|
10
|
+
builder: yargs => json_option(tenant_options(yargs))
|
|
11
|
+
.positional('client_id', {
|
|
12
|
+
type: 'string',
|
|
13
|
+
demandOption: true,
|
|
14
|
+
description: 'Client resource id or OAuth client_id'
|
|
15
|
+
})
|
|
16
|
+
.option('secret', {
|
|
17
|
+
type: 'boolean',
|
|
18
|
+
default: false,
|
|
19
|
+
description: 'Also print the client secret'
|
|
20
|
+
})
|
|
21
|
+
.showHelpOnFail(false),
|
|
22
|
+
handler: withAuthHints(async (args) => {
|
|
23
|
+
const api = await requireAuthAdmin(args);
|
|
24
|
+
const client = await resolve_client(api, args.client_id);
|
|
25
|
+
if (args.json)
|
|
26
|
+
return print_json(client);
|
|
27
|
+
log.info(`🔑 ${client.name ?? client.id}`);
|
|
28
|
+
log.info(` ID: ${client.id}`);
|
|
29
|
+
log.info(` Client ID: ${client.client_id ?? '-'}`);
|
|
30
|
+
if (args.secret) {
|
|
31
|
+
log.info(` Secret: ${client.client_secret ?? '-'}`);
|
|
32
|
+
}
|
|
33
|
+
if (client.description)
|
|
34
|
+
log.info(` Description: ${client.description}`);
|
|
35
|
+
log.info(` Callbacks: ${client.callbacks?.length ? client.callbacks.join(', ') : '-'}`);
|
|
36
|
+
if (client.logout_urls?.length) {
|
|
37
|
+
log.info(` Logout URLs: ${client.logout_urls.join(', ')}`);
|
|
38
|
+
}
|
|
39
|
+
if (client.web_origins?.length) {
|
|
40
|
+
log.info(` Web origins: ${client.web_origins.join(', ')}`);
|
|
41
|
+
}
|
|
42
|
+
log.info(` Created: ${client.createdAt ?? '-'}`);
|
|
43
|
+
})
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export { clients_get };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { clients_create } from './create.js';
|
|
2
|
+
import { clients_get } from './get.js';
|
|
3
|
+
import { clients_list } from './list.js';
|
|
4
|
+
import { clients_rm } from './rm.js';
|
|
5
|
+
|
|
6
|
+
const clients = {
|
|
7
|
+
command: 'clients',
|
|
8
|
+
describe: 'Manage OAuth clients',
|
|
9
|
+
builder: yargs => yargs
|
|
10
|
+
.command(clients_list)
|
|
11
|
+
.command(clients_get)
|
|
12
|
+
.command(clients_create)
|
|
13
|
+
.command(clients_rm)
|
|
14
|
+
.demandCommand(1)
|
|
15
|
+
.showHelpOnFail(false),
|
|
16
|
+
handler: () => { }
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export { clients };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
|
|
2
|
+
import { log } from '../../../log.js';
|
|
3
|
+
import { json_option, list_options, tenant_options } from '../options.js';
|
|
4
|
+
import { fetch_items } from '../paging.js';
|
|
5
|
+
import { print_json, truncate, when, table_lines } from '../render.js';
|
|
6
|
+
|
|
7
|
+
const clients_list = {
|
|
8
|
+
command: 'list',
|
|
9
|
+
describe: 'List OAuth clients',
|
|
10
|
+
builder: yargs => json_option(list_options(tenant_options(yargs)))
|
|
11
|
+
.option('q', {
|
|
12
|
+
type: 'string',
|
|
13
|
+
description: 'Full-text search over name/description/client_id'
|
|
14
|
+
})
|
|
15
|
+
.showHelpOnFail(false),
|
|
16
|
+
handler: withAuthHints(async (args) => {
|
|
17
|
+
const api = await requireAuthAdmin(args);
|
|
18
|
+
const { items, more } = await fetch_items(api.clientList({ q: args.q, pageSize: args.limit }), args.all);
|
|
19
|
+
if (args.json)
|
|
20
|
+
return print_json(items);
|
|
21
|
+
if (items.length === 0) {
|
|
22
|
+
log.info('📭 No clients.');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
log.info(`🔑 ${items.length} client(s):`);
|
|
26
|
+
const rows = items.map(c => [
|
|
27
|
+
c.client_id ?? '-',
|
|
28
|
+
truncate(c.name, 28),
|
|
29
|
+
String(c.callbacks?.length ?? 0),
|
|
30
|
+
when(c.createdAt)
|
|
31
|
+
]);
|
|
32
|
+
for (const line of table_lines(['CLIENT_ID', 'NAME', 'CALLBACKS', 'CREATED'], rows)) {
|
|
33
|
+
log.info(` ${line}`);
|
|
34
|
+
}
|
|
35
|
+
if (more) {
|
|
36
|
+
log.info('… more results available: raise --limit or use --all.');
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export { clients_list };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Accept either the resource id (the /client/:id param) or the OAuth
|
|
2
|
+
// `client_id`. The direct lookup rejects a non-resource id with 400 (schema
|
|
3
|
+
// format) or 404 — on either, fall back to a search (the management list is
|
|
4
|
+
// searchable by client_id) and use a single exact match.
|
|
5
|
+
const resolve_client = async (api, id) => {
|
|
6
|
+
try {
|
|
7
|
+
return await api.clientGet(id);
|
|
8
|
+
}
|
|
9
|
+
catch (e) {
|
|
10
|
+
const status = e?.response?.status;
|
|
11
|
+
if (status !== 404 && status !== 400)
|
|
12
|
+
throw e;
|
|
13
|
+
const candidates = await api.clientList({ q: id, pageSize: 10 }).pass();
|
|
14
|
+
const exact = candidates.results.filter(c => c.client_id === id);
|
|
15
|
+
if (exact.length === 1)
|
|
16
|
+
return exact[0];
|
|
17
|
+
throw new Error(`Client not found: ${id}`, { cause: e });
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export { resolve_client };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import prompts from 'prompts';
|
|
2
|
+
import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
|
|
3
|
+
import { log } from '../../../log.js';
|
|
4
|
+
import { tenant_options } from '../options.js';
|
|
5
|
+
import { resolve_client } from './resolve.js';
|
|
6
|
+
|
|
7
|
+
const clients_rm = {
|
|
8
|
+
command: 'rm <client_id>',
|
|
9
|
+
describe: 'Delete an OAuth client',
|
|
10
|
+
builder: yargs => tenant_options(yargs)
|
|
11
|
+
.positional('client_id', {
|
|
12
|
+
type: 'string',
|
|
13
|
+
demandOption: true,
|
|
14
|
+
description: 'Client resource id or OAuth client_id'
|
|
15
|
+
})
|
|
16
|
+
.option('yes', {
|
|
17
|
+
alias: 'y',
|
|
18
|
+
type: 'boolean',
|
|
19
|
+
default: false,
|
|
20
|
+
description: 'Skip the confirmation prompt'
|
|
21
|
+
})
|
|
22
|
+
.showHelpOnFail(false),
|
|
23
|
+
handler: withAuthHints(async (args) => {
|
|
24
|
+
const api = await requireAuthAdmin(args);
|
|
25
|
+
const client = await resolve_client(api, args.client_id);
|
|
26
|
+
if (!args.yes) {
|
|
27
|
+
const { confirm } = await prompts({
|
|
28
|
+
type: 'toggle',
|
|
29
|
+
name: 'confirm',
|
|
30
|
+
message: `Delete client "${client.name}" (${client.client_id})? Apps using it will stop authenticating.`,
|
|
31
|
+
initial: false,
|
|
32
|
+
active: 'yes',
|
|
33
|
+
inactive: 'no'
|
|
34
|
+
});
|
|
35
|
+
if (!confirm) {
|
|
36
|
+
log.info('Cancelled.');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
await api.clientDelete(client.id);
|
|
41
|
+
log.info(`🗑️ Deleted client ${client.client_id} (${client.id}).`);
|
|
42
|
+
})
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export { clients_rm };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { actions } from './actions/index.js';
|
|
2
|
+
import { clients } from './clients/index.js';
|
|
3
|
+
import { logs } from './logs/index.js';
|
|
4
|
+
import { users } from './users/index.js';
|
|
5
|
+
|
|
6
|
+
// `faable auth` — management commands for a Faable Auth tenant. Auth: reuses
|
|
7
|
+
// the `faable login` session (or FAABLE_TOKEN); tenant: --auth-url /
|
|
8
|
+
// FAABLE_AUTH_URL (defaults to the Faable tenant host). See
|
|
9
|
+
// src/api/auth_admin.ts.
|
|
10
|
+
const auth = {
|
|
11
|
+
command: 'auth',
|
|
12
|
+
describe: 'Manage Faable Auth (users, actions, clients, audit logs)',
|
|
13
|
+
builder: yargs => yargs
|
|
14
|
+
.command(users)
|
|
15
|
+
.command(actions)
|
|
16
|
+
.command(clients)
|
|
17
|
+
.command(logs)
|
|
18
|
+
.demandCommand(1)
|
|
19
|
+
.showHelpOnFail(false),
|
|
20
|
+
handler: () => { }
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export { auth };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
|
|
2
|
+
import { log } from '../../../log.js';
|
|
3
|
+
import { json_option, tenant_options } from '../options.js';
|
|
4
|
+
import { print_json, log_status_badge } from '../render.js';
|
|
5
|
+
|
|
6
|
+
const logs_get = {
|
|
7
|
+
command: 'get <log_id>',
|
|
8
|
+
describe: 'Show an audit log entry',
|
|
9
|
+
builder: yargs => json_option(tenant_options(yargs))
|
|
10
|
+
.positional('log_id', {
|
|
11
|
+
type: 'string',
|
|
12
|
+
demandOption: true,
|
|
13
|
+
description: 'Log entry identifier'
|
|
14
|
+
})
|
|
15
|
+
.showHelpOnFail(false),
|
|
16
|
+
handler: withAuthHints(async (args) => {
|
|
17
|
+
const api = await requireAuthAdmin(args);
|
|
18
|
+
const entry = await api.logGet(args.log_id);
|
|
19
|
+
if (args.json)
|
|
20
|
+
return print_json(entry);
|
|
21
|
+
log.info(`📜 ${entry.id}`);
|
|
22
|
+
log.info(` Date: ${entry.createdAt ?? '-'}`);
|
|
23
|
+
log.info(` Type: ${entry.type ?? '-'}`);
|
|
24
|
+
log.info(` Status: ${log_status_badge(entry.status)}`);
|
|
25
|
+
if (entry.message)
|
|
26
|
+
log.info(` Message: ${entry.message}`);
|
|
27
|
+
const refs = [
|
|
28
|
+
['User', entry.user],
|
|
29
|
+
['Client', entry.client],
|
|
30
|
+
['Connection', entry.connection],
|
|
31
|
+
['Team', entry.team],
|
|
32
|
+
['Identity', entry.identity]
|
|
33
|
+
];
|
|
34
|
+
for (const [label, ref] of refs) {
|
|
35
|
+
if (!ref)
|
|
36
|
+
continue;
|
|
37
|
+
const id = typeof ref === 'string' ? ref : ref.id;
|
|
38
|
+
if (id)
|
|
39
|
+
log.info(` ${label}:${' '.repeat(Math.max(1, 8 - label.length))}${id}`);
|
|
40
|
+
}
|
|
41
|
+
if (entry.data !== undefined && entry.data !== null) {
|
|
42
|
+
log.info(' Data:');
|
|
43
|
+
process.stdout.write(JSON.stringify(entry.data, null, 2) + '\n');
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export { logs_get };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { logs_get } from './get.js';
|
|
2
|
+
import { logs_list } from './list.js';
|
|
3
|
+
|
|
4
|
+
const logs = {
|
|
5
|
+
command: 'logs',
|
|
6
|
+
describe: 'Browse the audit log (read-only)',
|
|
7
|
+
builder: yargs => yargs
|
|
8
|
+
.command(logs_list)
|
|
9
|
+
.command(logs_get)
|
|
10
|
+
.demandCommand(1)
|
|
11
|
+
.showHelpOnFail(false),
|
|
12
|
+
handler: () => { }
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export { logs };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
|
|
2
|
+
import { log } from '../../../log.js';
|
|
3
|
+
import { json_option, list_options, tenant_options } from '../options.js';
|
|
4
|
+
import { fetch_items } from '../paging.js';
|
|
5
|
+
import { compose_query, term, time_term } from '../query.js';
|
|
6
|
+
import { print_json, log_status_badge, truncate, table_lines } from '../render.js';
|
|
7
|
+
|
|
8
|
+
const logs_list = {
|
|
9
|
+
command: 'list',
|
|
10
|
+
describe: 'List and filter audit logs',
|
|
11
|
+
builder: yargs => json_option(list_options(tenant_options(yargs)))
|
|
12
|
+
.option('query', {
|
|
13
|
+
type: 'string',
|
|
14
|
+
description: 'Raw FaableQL filter (combined with the flags below)'
|
|
15
|
+
})
|
|
16
|
+
.option('q', {
|
|
17
|
+
type: 'string',
|
|
18
|
+
description: 'Full-text search over the log message'
|
|
19
|
+
})
|
|
20
|
+
.option('type', {
|
|
21
|
+
type: 'string',
|
|
22
|
+
description: 'Exact event type, e.g. admin.user.updated'
|
|
23
|
+
})
|
|
24
|
+
.option('status', {
|
|
25
|
+
type: 'string',
|
|
26
|
+
choices: ['success', 'failed', 'skipped', 'info'],
|
|
27
|
+
description: 'Event status'
|
|
28
|
+
})
|
|
29
|
+
.option('origin', {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: 'Subsystem prefix, e.g. oauth (matches oauth.*)'
|
|
32
|
+
})
|
|
33
|
+
.option('user', {
|
|
34
|
+
type: 'string',
|
|
35
|
+
description: 'Filter by subject user id'
|
|
36
|
+
})
|
|
37
|
+
.option('client', {
|
|
38
|
+
type: 'string',
|
|
39
|
+
description: 'Filter by subject client id'
|
|
40
|
+
})
|
|
41
|
+
.option('since', {
|
|
42
|
+
type: 'string',
|
|
43
|
+
description: 'From date: unix-millis or YYYY-MM-DD'
|
|
44
|
+
})
|
|
45
|
+
.option('until', {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description: 'To date: unix-millis or YYYY-MM-DD'
|
|
48
|
+
})
|
|
49
|
+
.example('$0 auth logs list --user user_abc123 --since 2026-08-01', "One user's audit trail since August 1st")
|
|
50
|
+
.example('$0 auth logs list --origin oauth --status failed', 'Failed OAuth events')
|
|
51
|
+
.showHelpOnFail(false),
|
|
52
|
+
handler: withAuthHints(async (args) => {
|
|
53
|
+
const query = compose_query([
|
|
54
|
+
term('type', args.type),
|
|
55
|
+
term('status', args.status),
|
|
56
|
+
term('origin', args.origin),
|
|
57
|
+
term('user', args.user),
|
|
58
|
+
term('client', args.client),
|
|
59
|
+
time_term('since', args.since),
|
|
60
|
+
time_term('until', args.until)
|
|
61
|
+
], args.query);
|
|
62
|
+
const api = await requireAuthAdmin(args);
|
|
63
|
+
const { items, more } = await fetch_items(api.logList({ query, q: args.q, pageSize: args.limit }), args.all);
|
|
64
|
+
if (args.json)
|
|
65
|
+
return print_json(items);
|
|
66
|
+
if (items.length === 0) {
|
|
67
|
+
log.info('📭 No audit log entries match.');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
log.info(`📜 ${items.length} entr${items.length === 1 ? 'y' : 'ies'}:`);
|
|
71
|
+
const rows = items.map(entry => [
|
|
72
|
+
entry.createdAt ?? '-',
|
|
73
|
+
entry.type ?? '-',
|
|
74
|
+
log_status_badge(entry.status),
|
|
75
|
+
typeof entry.user === 'string'
|
|
76
|
+
? entry.user
|
|
77
|
+
: (entry.user?.id ?? '-'),
|
|
78
|
+
truncate(entry.message, 48),
|
|
79
|
+
entry.id ?? '-'
|
|
80
|
+
]);
|
|
81
|
+
for (const line of table_lines(['DATE', 'TYPE', 'STATUS', 'USER', 'MESSAGE', 'ID'], rows)) {
|
|
82
|
+
log.info(` ${line}`);
|
|
83
|
+
}
|
|
84
|
+
if (more) {
|
|
85
|
+
log.info('… more results available: raise --limit, use --all, or narrow with --since/--type.');
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export { logs_list };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const tenant_options = (yargs) => yargs
|
|
2
|
+
.option('auth-url', {
|
|
3
|
+
type: 'string',
|
|
4
|
+
description: 'Auth tenant base URL, e.g. https://<account>.auth.faable.link (env FAABLE_AUTH_URL)'
|
|
5
|
+
})
|
|
6
|
+
.option('account', {
|
|
7
|
+
type: 'string',
|
|
8
|
+
description: 'Resolve a different account id on the target host (env FAABLE_AUTH_ACCOUNT)'
|
|
9
|
+
});
|
|
10
|
+
const json_option = (yargs) => yargs.option('json', {
|
|
11
|
+
type: 'boolean',
|
|
12
|
+
default: false,
|
|
13
|
+
description: 'Output raw JSON (for scripting)'
|
|
14
|
+
});
|
|
15
|
+
const list_options = (yargs) => yargs
|
|
16
|
+
.option('limit', {
|
|
17
|
+
type: 'number',
|
|
18
|
+
default: 100,
|
|
19
|
+
description: 'Max items to fetch in one page (1-200)'
|
|
20
|
+
})
|
|
21
|
+
.option('all', {
|
|
22
|
+
type: 'boolean',
|
|
23
|
+
default: false,
|
|
24
|
+
description: 'Fetch every page (ignores --limit)'
|
|
25
|
+
})
|
|
26
|
+
.check(argv => {
|
|
27
|
+
const limit = argv.limit;
|
|
28
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) {
|
|
29
|
+
throw new Error('--limit must be an integer between 1 and 200');
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export { json_option, list_options, tenant_options };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Pure helpers to compose FaableQL filter strings from CLI flags.
|
|
2
|
+
//
|
|
3
|
+
// FaableQL grammar (server-side, @faablecloud/faableql): space-separated
|
|
4
|
+
// `field:value` terms, ANDed; values only allow [alnum _ - @ .], so a `:`
|
|
5
|
+
// inside a value (e.g. an ISO-8601 timestamp) is a parse error — dates must be
|
|
6
|
+
// unix-millis or YYYY-MM-DD.
|
|
7
|
+
// Join flag-derived terms with a user-provided --query passthrough.
|
|
8
|
+
const compose_query = (terms, passthrough) => {
|
|
9
|
+
const parts = terms.filter((t) => !!t);
|
|
10
|
+
if (passthrough?.trim())
|
|
11
|
+
parts.push(passthrough.trim());
|
|
12
|
+
return parts.length ? parts.join(' ') : undefined;
|
|
13
|
+
};
|
|
14
|
+
// Build a single `field:value` term, refusing values FaableQL cannot parse
|
|
15
|
+
// (anything outside [alnum _ - @ .]) with a readable error instead of a
|
|
16
|
+
// server-side 400.
|
|
17
|
+
const term = (field, value) => {
|
|
18
|
+
if (value === undefined || value === '')
|
|
19
|
+
return undefined;
|
|
20
|
+
if (!/^[a-zA-Z0-9_\-@.]+$/.test(value)) {
|
|
21
|
+
throw new Error(`Invalid value for --${field}: "${value}" (allowed: letters, digits, _ - @ .)`);
|
|
22
|
+
}
|
|
23
|
+
return `${field}:${value}`;
|
|
24
|
+
};
|
|
25
|
+
// Validate a --since/--until value: unix-millis or YYYY-MM-DD. ISO timestamps
|
|
26
|
+
// carry ':' which FaableQL rejects, so we fail fast with the fix.
|
|
27
|
+
const time_term = (field, value) => {
|
|
28
|
+
if (value === undefined || value === '')
|
|
29
|
+
return undefined;
|
|
30
|
+
if (!/^\d+$/.test(value) && !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
31
|
+
throw new Error(`Invalid --${field}: "${value}". Use unix-millis or YYYY-MM-DD (ISO timestamps with ':' are not supported)`);
|
|
32
|
+
}
|
|
33
|
+
return `${field}:${value}`;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export { compose_query, term, time_term };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Pure rendering helpers for the `faable auth` read commands.
|
|
2
|
+
const print_json = (data) => {
|
|
3
|
+
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
4
|
+
};
|
|
5
|
+
// Left-padded fixed-width table lines. Cells are stringified as-is; column
|
|
6
|
+
// width = max(header, cells).
|
|
7
|
+
const table_lines = (headers, rows) => {
|
|
8
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => (r[i] ?? '').length)));
|
|
9
|
+
const render = (cells) => cells
|
|
10
|
+
.map((c, i) => (i === cells.length - 1 ? c : (c ?? '').padEnd(widths[i])))
|
|
11
|
+
.join(' ')
|
|
12
|
+
.trimEnd();
|
|
13
|
+
return [render(headers), ...rows.map(render)];
|
|
14
|
+
};
|
|
15
|
+
const yes_no = (v) => (v ? '✓' : '-');
|
|
16
|
+
const truncate = (s, max) => {
|
|
17
|
+
if (!s)
|
|
18
|
+
return '-';
|
|
19
|
+
const one_line = s.replace(/\s+/g, ' ').trim();
|
|
20
|
+
return one_line.length > max ? one_line.slice(0, max - 1) + '…' : one_line;
|
|
21
|
+
};
|
|
22
|
+
// Relative age, matching the deploy commands' `when` formatting.
|
|
23
|
+
const when = (iso) => {
|
|
24
|
+
if (!iso)
|
|
25
|
+
return '-';
|
|
26
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
27
|
+
const minutes = Math.floor(ms / 60_000);
|
|
28
|
+
if (minutes < 1)
|
|
29
|
+
return 'just now';
|
|
30
|
+
if (minutes < 60)
|
|
31
|
+
return `${minutes}m ago`;
|
|
32
|
+
const hours = Math.floor(minutes / 60);
|
|
33
|
+
if (hours < 48)
|
|
34
|
+
return `${hours}h ago`;
|
|
35
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
36
|
+
};
|
|
37
|
+
const STATUS_ICONS = {
|
|
38
|
+
success: '🟢',
|
|
39
|
+
info: '🔵',
|
|
40
|
+
skipped: '⚪',
|
|
41
|
+
failed: '🔴'
|
|
42
|
+
};
|
|
43
|
+
const log_status_badge = (status) => {
|
|
44
|
+
const s = status || 'info';
|
|
45
|
+
return `${STATUS_ICONS[s] ?? '⚪'} ${s}`;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export { log_status_badge, print_json, table_lines, truncate, when, yes_no };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
|
|
2
|
+
import { log } from '../../../log.js';
|
|
3
|
+
import { json_option, tenant_options } from '../options.js';
|
|
4
|
+
import { print_json, yes_no, when } from '../render.js';
|
|
5
|
+
|
|
6
|
+
const users_get = {
|
|
7
|
+
command: 'get <user_id>',
|
|
8
|
+
describe: 'Show a user',
|
|
9
|
+
builder: yargs => json_option(tenant_options(yargs))
|
|
10
|
+
.positional('user_id', {
|
|
11
|
+
type: 'string',
|
|
12
|
+
demandOption: true,
|
|
13
|
+
description: 'User identifier (user_…)'
|
|
14
|
+
})
|
|
15
|
+
.showHelpOnFail(false),
|
|
16
|
+
handler: withAuthHints(async (args) => {
|
|
17
|
+
const api = await requireAuthAdmin(args);
|
|
18
|
+
const user = await api.userGet(args.user_id);
|
|
19
|
+
if (args.json)
|
|
20
|
+
return print_json(user);
|
|
21
|
+
log.info(`👤 ${user.id}`);
|
|
22
|
+
log.info(` Email: ${user.email ?? '-'} (verified: ${yes_no(user.email_verified)})`);
|
|
23
|
+
log.info(` Name: ${user.name ?? '-'}`);
|
|
24
|
+
if (user.phone)
|
|
25
|
+
log.info(` Phone: ${user.phone}`);
|
|
26
|
+
if (user.country_iso)
|
|
27
|
+
log.info(` Country: ${user.country_iso}`);
|
|
28
|
+
log.info(` Created: ${user.createdAt ?? '-'}`);
|
|
29
|
+
log.info(` Last login: ${user.last_login ? `${user.last_login} (${when(user.last_login)})` : '-'}${user.last_ip ? ` from ${user.last_ip}` : ''}`);
|
|
30
|
+
log.info(` Logins: ${user.logins_count ?? 0}`);
|
|
31
|
+
if (user.suspended) {
|
|
32
|
+
log.info(` Suspended: 🔴 yes (${user.suspended_at ?? 'unknown date'})`);
|
|
33
|
+
if (user.suspended_reason)
|
|
34
|
+
log.info(` Reason: ${user.suspended_reason}`);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
log.info(' Suspended: no');
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export { users_get };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Resolve the target user ids for a bulk operation. Ids come from argv, from
|
|
2
|
+
// stdin when none are given and input is piped (`… list --json | jq -r … |
|
|
3
|
+
// faable auth users suspend`), or from a '-' placeholder. NB: yargs drops a
|
|
4
|
+
// bare '-' from positionals, so piping with NO ids is the supported spelling;
|
|
5
|
+
// '-' is still honored if it survives parsing. Dedupes, keeps order, and
|
|
6
|
+
// rejects anything that doesn't look like a user id BEFORE any mutation runs.
|
|
7
|
+
const wants_stdin = (args, is_tty) => args.includes('-') || (args.length === 0 && !is_tty);
|
|
8
|
+
const parse_user_ids = (args, stdin) => {
|
|
9
|
+
const stdin_tokens = stdin === null ? null : stdin.split(/\s+/).filter(Boolean);
|
|
10
|
+
const raw = [];
|
|
11
|
+
let stdin_used = false;
|
|
12
|
+
for (const a of args) {
|
|
13
|
+
if (a === '-') {
|
|
14
|
+
if (stdin_tokens === null) {
|
|
15
|
+
throw new Error("'-' requires ids on stdin (one or more, whitespace-separated)");
|
|
16
|
+
}
|
|
17
|
+
raw.push(...stdin_tokens);
|
|
18
|
+
stdin_used = true;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
raw.push(a);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (stdin_tokens !== null && !stdin_used) {
|
|
25
|
+
raw.push(...stdin_tokens);
|
|
26
|
+
}
|
|
27
|
+
const ids = [...new Set(raw)];
|
|
28
|
+
if (ids.length === 0) {
|
|
29
|
+
throw new Error('No user ids given (pass ids, or pipe them via stdin)');
|
|
30
|
+
}
|
|
31
|
+
const invalid = ids.filter(id => !/^user_[a-zA-Z0-9]+$/.test(id));
|
|
32
|
+
if (invalid.length > 0) {
|
|
33
|
+
throw new Error(`Invalid user id(s): ${invalid.join(', ')} (expected user_…)`);
|
|
34
|
+
}
|
|
35
|
+
return ids;
|
|
36
|
+
};
|
|
37
|
+
const read_stdin = async () => {
|
|
38
|
+
const chunks = [];
|
|
39
|
+
for await (const chunk of process.stdin) {
|
|
40
|
+
chunks.push(Buffer.from(chunk));
|
|
41
|
+
}
|
|
42
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export { parse_user_ids, read_stdin, wants_stdin };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { users_get } from './get.js';
|
|
2
|
+
import { users_list } from './list.js';
|
|
3
|
+
import { users_suspend } from './suspend.js';
|
|
4
|
+
|
|
5
|
+
const users = {
|
|
6
|
+
command: 'users',
|
|
7
|
+
describe: 'List, inspect and suspend users',
|
|
8
|
+
builder: yargs => yargs
|
|
9
|
+
.command(users_list)
|
|
10
|
+
.command(users_get)
|
|
11
|
+
.command(users_suspend)
|
|
12
|
+
.demandCommand(1)
|
|
13
|
+
.showHelpOnFail(false),
|
|
14
|
+
handler: () => { }
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export { users };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
|
|
2
|
+
import { log } from '../../../log.js';
|
|
3
|
+
import { json_option, list_options, tenant_options } from '../options.js';
|
|
4
|
+
import { compose_query } from '../query.js';
|
|
5
|
+
import { fetch_items } from '../paging.js';
|
|
6
|
+
import { print_json, truncate, yes_no, when, table_lines } from '../render.js';
|
|
7
|
+
|
|
8
|
+
const users_list = {
|
|
9
|
+
command: 'list',
|
|
10
|
+
describe: 'List and filter users',
|
|
11
|
+
builder: yargs => json_option(list_options(tenant_options(yargs)))
|
|
12
|
+
.option('query', {
|
|
13
|
+
type: 'string',
|
|
14
|
+
description: 'FaableQL filter, e.g. "suspended:true email_verified:false" (fields: email, name, phone, suspended, email_verified, country_iso, locale, last_ip)'
|
|
15
|
+
})
|
|
16
|
+
.option('q', {
|
|
17
|
+
type: 'string',
|
|
18
|
+
description: 'Full-text search over name/email/phone'
|
|
19
|
+
})
|
|
20
|
+
.option('suspended', {
|
|
21
|
+
type: 'boolean',
|
|
22
|
+
description: 'Only suspended users (shorthand for query suspended:true)'
|
|
23
|
+
})
|
|
24
|
+
.example('$0 auth users list --suspended', 'List suspended users')
|
|
25
|
+
.example('$0 auth users list --query email_verified:false --limit 50', 'First 50 unverified users')
|
|
26
|
+
.showHelpOnFail(false),
|
|
27
|
+
handler: withAuthHints(async (args) => {
|
|
28
|
+
const api = await requireAuthAdmin(args);
|
|
29
|
+
const query = compose_query([args.suspended !== undefined && `suspended:${args.suspended}`], args.query);
|
|
30
|
+
const { items, more } = await fetch_items(api.userList({ query, q: args.q, pageSize: args.limit }), args.all);
|
|
31
|
+
if (args.json)
|
|
32
|
+
return print_json(items);
|
|
33
|
+
if (items.length === 0) {
|
|
34
|
+
log.info('📭 No users match.');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
log.info(`👥 ${items.length} user(s):`);
|
|
38
|
+
const rows = items.map(u => [
|
|
39
|
+
u.id ?? '-',
|
|
40
|
+
truncate(u.email, 32),
|
|
41
|
+
truncate(u.name, 24),
|
|
42
|
+
yes_no(u.email_verified),
|
|
43
|
+
u.suspended ? '🔴 suspended' : '-',
|
|
44
|
+
when(u.last_login)
|
|
45
|
+
]);
|
|
46
|
+
for (const line of table_lines(['ID', 'EMAIL', 'NAME', 'VERIFIED', 'SUSPENDED', 'LAST LOGIN'], rows)) {
|
|
47
|
+
log.info(` ${line}`);
|
|
48
|
+
}
|
|
49
|
+
if (more) {
|
|
50
|
+
log.info('… more results available: raise --limit, use --all, or refine the filter.');
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export { users_list };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import prompts from 'prompts';
|
|
2
|
+
import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
|
|
3
|
+
import { log } from '../../../log.js';
|
|
4
|
+
import { json_option, tenant_options } from '../options.js';
|
|
5
|
+
import { print_json } from '../render.js';
|
|
6
|
+
import { wants_stdin, read_stdin, parse_user_ids } from './ids.js';
|
|
7
|
+
|
|
8
|
+
const users_suspend = {
|
|
9
|
+
command: 'suspend [user_ids..]',
|
|
10
|
+
describe: 'Suspend one or more users (blocks logins, refresh tokens and sessions)',
|
|
11
|
+
builder: yargs => json_option(tenant_options(yargs))
|
|
12
|
+
.positional('user_ids', {
|
|
13
|
+
type: 'string',
|
|
14
|
+
array: true,
|
|
15
|
+
description: 'User ids (user_…); omit them to read ids from stdin'
|
|
16
|
+
})
|
|
17
|
+
.option('reason', {
|
|
18
|
+
alias: 'r',
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'Suspension reason, recorded on the user (max 512 chars)'
|
|
21
|
+
})
|
|
22
|
+
.option('yes', {
|
|
23
|
+
alias: 'y',
|
|
24
|
+
type: 'boolean',
|
|
25
|
+
default: false,
|
|
26
|
+
description: 'Skip the confirmation prompt'
|
|
27
|
+
})
|
|
28
|
+
.example('$0 auth users suspend user_abc123 --reason "abuse: proxy payload"', 'Suspend one user')
|
|
29
|
+
.example('$0 auth users suspend user_a user_b user_c -y -r "abuse wave"', 'Suspend several users without prompting')
|
|
30
|
+
.example('faable auth users list --query email_verified:false --json | jq -r ".[].id" | $0 auth users suspend -y', 'Bulk-suspend ids piped from a filtered listing')
|
|
31
|
+
.showHelpOnFail(false),
|
|
32
|
+
handler: withAuthHints(async (args) => {
|
|
33
|
+
const argv_ids = args.user_ids ?? [];
|
|
34
|
+
const stdin = wants_stdin(argv_ids, !!process.stdin.isTTY)
|
|
35
|
+
? await read_stdin()
|
|
36
|
+
: null;
|
|
37
|
+
const ids = parse_user_ids(argv_ids, stdin);
|
|
38
|
+
if (!args.yes) {
|
|
39
|
+
const preview = ids.slice(0, 5).join(', ') + (ids.length > 5 ? ', …' : '');
|
|
40
|
+
// In a non-TTY run without --yes, prompts resolves undefined → cancel.
|
|
41
|
+
const { confirm } = await prompts({
|
|
42
|
+
type: 'toggle',
|
|
43
|
+
name: 'confirm',
|
|
44
|
+
message: `Suspend ${ids.length} user(s) (${preview})?`,
|
|
45
|
+
initial: false,
|
|
46
|
+
active: 'yes',
|
|
47
|
+
inactive: 'no'
|
|
48
|
+
});
|
|
49
|
+
if (!confirm) {
|
|
50
|
+
log.info('Cancelled.');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const api = await requireAuthAdmin(args);
|
|
55
|
+
const results = [];
|
|
56
|
+
for (const id of ids) {
|
|
57
|
+
try {
|
|
58
|
+
const user = await api.userUpdate(id, {
|
|
59
|
+
suspended: true,
|
|
60
|
+
...(args.reason ? { suspended_reason: args.reason } : {})
|
|
61
|
+
});
|
|
62
|
+
results.push({ id, suspended: !!user.suspended });
|
|
63
|
+
// Progress lines stay off stdout in --json mode (machine-clean pipe).
|
|
64
|
+
if (!args.json) {
|
|
65
|
+
log.info(`🔴 Suspended ${id}${user.email ? ` (${user.email})` : ''}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
70
|
+
results.push({ id, suspended: false, error: message });
|
|
71
|
+
if (!args.json)
|
|
72
|
+
log.error(`❌ Failed to suspend ${id}: ${message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (args.json)
|
|
76
|
+
print_json(results);
|
|
77
|
+
const failed = results.filter(r => r.error);
|
|
78
|
+
if (failed.length > 0) {
|
|
79
|
+
throw new Error(`${failed.length} of ${ids.length} suspension(s) failed${args.json ? '' : ' — see errors above'}`);
|
|
80
|
+
}
|
|
81
|
+
if (!args.json) {
|
|
82
|
+
log.info(`✅ ${ids.length} user(s) suspended. Live access tokens against external APIs remain valid until expiry (≤24h).`);
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export { users_suspend };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import yargs from 'yargs';
|
|
2
2
|
import { hideBin } from 'yargs/helpers';
|
|
3
|
+
import { auth } from './commands/auth/index.js';
|
|
3
4
|
import { deploy } from './commands/deploy/index.js';
|
|
4
5
|
import { link_deprecated } from './commands/link/index.js';
|
|
5
6
|
import { login } from './commands/login/index.js';
|
|
@@ -22,6 +23,10 @@ yg.scriptName('faable')
|
|
|
22
23
|
if (banner_shown)
|
|
23
24
|
return;
|
|
24
25
|
banner_shown = true;
|
|
26
|
+
// --json mode is for piping: keep stdout machine-clean (no banner, no
|
|
27
|
+
// update-check notice).
|
|
28
|
+
if (argv.json)
|
|
29
|
+
return;
|
|
25
30
|
log.info(`Faable CLI ${version}`);
|
|
26
31
|
// `upgrade` does its own (forced) check
|
|
27
32
|
if (argv._[0] !== 'upgrade') {
|
|
@@ -44,6 +49,7 @@ yg.scriptName('faable')
|
|
|
44
49
|
}
|
|
45
50
|
}, true)
|
|
46
51
|
.command(deploy)
|
|
52
|
+
.command(auth)
|
|
47
53
|
.command(login)
|
|
48
54
|
.command(logout)
|
|
49
55
|
.command(whoami)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@faable/faable",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.32.0",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Pomar <marc@faable.com>",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@actions/core": "^3.0.0",
|
|
30
|
+
"@faable/auth-sdk": "^2.5.18",
|
|
30
31
|
"axios": "^1.18.1",
|
|
31
32
|
"fs-extra": "^11.3.2",
|
|
32
33
|
"handlebars": "^4.7.8",
|