@k2b/cloud 0.15.0 → 0.17.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/package.json +2 -2
- package/scripts/build.ts +11 -0
- package/src/_internal/cli-plugins.ts +248 -0
- package/src/_internal/define-app.ts +22 -1
- package/src/_internal/registry-validation.ts +7 -0
- package/src/access/PrincipalPicker.tsx +6 -2
- package/src/access/messages.ts +4 -0
- package/src/account/EntitySearch.tsx +8 -4
- package/src/account/avatar-upload-dialog.tsx +3 -2
- package/src/ai/chat/blocks.tsx +5 -2
- package/src/ai/chat/messages.ts +4 -0
- package/src/ai/chat/presentation.tsx +7 -2
- package/src/ai/pdf-render.ts +11 -2
- package/src/ai/routes.ts +5 -4
- package/src/api/admin-identity.ts +131 -3
- package/src/api/cli-plugins.ts +17 -0
- package/src/api/index.ts +1 -0
- package/src/cli/access.ts +11 -3
- package/src/cli/account.ts +6 -0
- package/src/cli/admin/agents.ts +219 -0
- package/src/cli/admin/index.ts +34 -0
- package/src/cli/index.ts +23 -0
- package/src/cli/plugin.ts +127 -0
- package/src/contracts/app.ts +13 -0
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +6 -4
- package/src/server/services/access.ts +16 -9
- package/src/services/accounts/entities.ts +7 -1
- package/src/services/index.ts +2 -1
- package/src/services/service-account-credentials.ts +73 -21
- package/src/services/service-accounts.ts +70 -1
- package/src/services/settings/core-settings.ts +13 -0
- package/src/ssr/Layout.tsx +3 -1
- package/src/styles/global.css +4 -0
- package/src/types/ambient.d.ts +5 -0
|
@@ -6,11 +6,12 @@ import { z } from "zod";
|
|
|
6
6
|
import { listApps } from "../_internal/registry";
|
|
7
7
|
import { CapabilityAppIdSchema } from "../contracts/capabilities";
|
|
8
8
|
import { type AuthContext, auth, err, respond, v } from "../server";
|
|
9
|
+
import { audit } from "../services/audit";
|
|
9
10
|
import { getIdentitySigningKeyStatus, identityMetrics, revokeIdentitySigningKey, rewrapIdentitySigningKeys } from "../services/identity";
|
|
10
11
|
import { WORKLOAD_SCOPES } from "../services/identity/workload-auth";
|
|
11
12
|
import { mandateMetrics, mandates } from "../services/mandates";
|
|
12
13
|
import { serviceAccountCredentials } from "../services/service-account-credentials";
|
|
13
|
-
import { serviceAccounts } from "../services/service-accounts";
|
|
14
|
+
import { isStandaloneServiceAccountKind, STANDALONE_SERVICE_ACCOUNT_KINDS, serviceAccounts } from "../services/service-accounts";
|
|
14
15
|
|
|
15
16
|
const WorkloadCredentialInputSchema = z
|
|
16
17
|
.object({
|
|
@@ -41,14 +42,44 @@ const MandateListQuerySchema = z
|
|
|
41
42
|
})
|
|
42
43
|
.strict();
|
|
43
44
|
|
|
45
|
+
const StandaloneServiceAccountKindSchema = z.enum(STANDALONE_SERVICE_ACCOUNT_KINDS);
|
|
46
|
+
const ServiceAccountParamsSchema = z.object({ id: z.uuid() }).strict();
|
|
47
|
+
const ServiceAccountListQuerySchema = z
|
|
48
|
+
.object({
|
|
49
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
50
|
+
perPage: z.coerce.number().int().min(1).max(500).default(100),
|
|
51
|
+
kind: StandaloneServiceAccountKindSchema.optional(),
|
|
52
|
+
status: z.enum(["active", "disabled"]).optional(),
|
|
53
|
+
search: z.string().trim().max(200).optional(),
|
|
54
|
+
})
|
|
55
|
+
.strict();
|
|
56
|
+
const ServiceAccountInputSchema = z
|
|
57
|
+
.object({
|
|
58
|
+
name: z.string().trim().min(1).max(120),
|
|
59
|
+
kind: StandaloneServiceAccountKindSchema.default("standalone"),
|
|
60
|
+
})
|
|
61
|
+
.strict();
|
|
62
|
+
const ServiceAccountStatusInputSchema = z.object({ status: z.enum(["active", "disabled"]) }).strict();
|
|
63
|
+
const ServiceAccountApiKeyInputSchema = z
|
|
64
|
+
.object({
|
|
65
|
+
name: z.string().trim().min(1).max(120),
|
|
66
|
+
scopes: z.array(z.string().trim().min(1).max(64)).max(20).default([]),
|
|
67
|
+
expiresAt: z.iso.datetime().nullable().optional(),
|
|
68
|
+
})
|
|
69
|
+
.strict();
|
|
70
|
+
|
|
44
71
|
type IdentityAdminDependencies = {
|
|
45
72
|
apps: () => Promise<Array<{ id: string; name: string }>>;
|
|
46
73
|
status: typeof getIdentitySigningKeyStatus;
|
|
47
74
|
rewrap: typeof rewrapIdentitySigningKeys;
|
|
48
75
|
revoke: typeof revokeIdentitySigningKey;
|
|
49
|
-
serviceAccounts: Pick<typeof serviceAccounts, "getOrCreateResourceBound">;
|
|
50
|
-
credentials: Pick<
|
|
76
|
+
serviceAccounts: Pick<typeof serviceAccounts, "getOrCreateResourceBound" | "createStandalone" | "listStandalone" | "get" | "setStatus">;
|
|
77
|
+
credentials: Pick<
|
|
78
|
+
typeof serviceAccountCredentials,
|
|
79
|
+
"createResourceApiToken" | "createStandaloneApiToken" | "getOverview" | "listOverview" | "revoke"
|
|
80
|
+
>;
|
|
51
81
|
mandates: Pick<typeof mandates, "list">;
|
|
82
|
+
audit: Pick<typeof audit, "record">;
|
|
52
83
|
};
|
|
53
84
|
|
|
54
85
|
const dependencies: IdentityAdminDependencies = {
|
|
@@ -59,8 +90,16 @@ const dependencies: IdentityAdminDependencies = {
|
|
|
59
90
|
serviceAccounts,
|
|
60
91
|
credentials: serviceAccountCredentials,
|
|
61
92
|
mandates,
|
|
93
|
+
audit,
|
|
62
94
|
};
|
|
63
95
|
|
|
96
|
+
const auditActor = (user: AuthContext["Variables"]["user"]) => ({
|
|
97
|
+
userId: user.id,
|
|
98
|
+
uid: user.uid,
|
|
99
|
+
provider: user.provider,
|
|
100
|
+
roles: user.roles,
|
|
101
|
+
});
|
|
102
|
+
|
|
64
103
|
export const createAdminIdentityRoutes = (
|
|
65
104
|
authenticate: MiddlewareHandler<AuthContext> = auth.requireRole("admin"),
|
|
66
105
|
service: IdentityAdminDependencies = dependencies,
|
|
@@ -158,6 +197,95 @@ export const createAdminIdentityRoutes = (
|
|
|
158
197
|
return revoked.ok ? ok({ revoked: true as const }) : revoked;
|
|
159
198
|
}),
|
|
160
199
|
)
|
|
200
|
+
// Standalone principals: service accounts with no delegated user and no
|
|
201
|
+
// resource binding. Access is granted to them like to a user; a new account
|
|
202
|
+
// starts with nothing. Credentials: OAuth client (OAuth admin API) or the
|
|
203
|
+
// API key minted below.
|
|
204
|
+
.get(
|
|
205
|
+
"/service-accounts",
|
|
206
|
+
describeRoute({ tags: ["Service accounts"], summary: "List standalone service accounts (kind standalone or agent)" }),
|
|
207
|
+
v("query", ServiceAccountListQuerySchema),
|
|
208
|
+
async (c) => c.json(await service.serviceAccounts.listStandalone(c.req.valid("query"))),
|
|
209
|
+
)
|
|
210
|
+
.post(
|
|
211
|
+
"/service-accounts",
|
|
212
|
+
describeRoute({ tags: ["Service accounts"], summary: "Create a standalone service account without any access" }),
|
|
213
|
+
v("json", ServiceAccountInputSchema),
|
|
214
|
+
async (c) =>
|
|
215
|
+
respond(
|
|
216
|
+
c,
|
|
217
|
+
async () => {
|
|
218
|
+
const input = c.req.valid("json");
|
|
219
|
+
const actor = c.get("user");
|
|
220
|
+
const created = await service.serviceAccounts.createStandalone({ ...input, createdBy: actor.id });
|
|
221
|
+
if (!created.ok) return created;
|
|
222
|
+
await service.audit.record({
|
|
223
|
+
action: "service_account.create",
|
|
224
|
+
outcome: "allowed",
|
|
225
|
+
actor: auditActor(actor),
|
|
226
|
+
target: { type: "service_account", id: created.data.id, label: created.data.name },
|
|
227
|
+
metadata: { serviceAccountId: created.data.id, serviceAccountKind: created.data.kind },
|
|
228
|
+
});
|
|
229
|
+
return created;
|
|
230
|
+
},
|
|
231
|
+
201,
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
.get(
|
|
235
|
+
"/service-accounts/:id",
|
|
236
|
+
describeRoute({ tags: ["Service accounts"], summary: "Read one standalone service account" }),
|
|
237
|
+
v("param", ServiceAccountParamsSchema),
|
|
238
|
+
async (c) =>
|
|
239
|
+
respond(c, async () => {
|
|
240
|
+
const account = await service.serviceAccounts.get({ id: c.req.valid("param").id });
|
|
241
|
+
return account && isStandaloneServiceAccountKind(account.kind) ? ok(account) : fail(err.notFound("Service account"));
|
|
242
|
+
}),
|
|
243
|
+
)
|
|
244
|
+
.patch(
|
|
245
|
+
"/service-accounts/:id",
|
|
246
|
+
describeRoute({
|
|
247
|
+
tags: ["Service accounts"],
|
|
248
|
+
summary: "Enable or disable a standalone service account; disabled accounts reject every credential immediately",
|
|
249
|
+
}),
|
|
250
|
+
v("param", ServiceAccountParamsSchema),
|
|
251
|
+
v("json", ServiceAccountStatusInputSchema),
|
|
252
|
+
async (c) =>
|
|
253
|
+
respond(c, async () => {
|
|
254
|
+
const { id } = c.req.valid("param");
|
|
255
|
+
const { status } = c.req.valid("json");
|
|
256
|
+
const actor = c.get("user");
|
|
257
|
+
const account = await service.serviceAccounts.get({ id });
|
|
258
|
+
if (!account || !isStandaloneServiceAccountKind(account.kind)) return fail(err.notFound("Service account"));
|
|
259
|
+
const updated = await service.serviceAccounts.setStatus({ id, status });
|
|
260
|
+
if (!updated.ok) return updated;
|
|
261
|
+
await service.audit.record({
|
|
262
|
+
action: status === "disabled" ? "service_account.disable" : "service_account.enable",
|
|
263
|
+
outcome: "allowed",
|
|
264
|
+
actor: auditActor(actor),
|
|
265
|
+
target: { type: "service_account", id: account.id, label: account.name },
|
|
266
|
+
metadata: { serviceAccountId: account.id, serviceAccountKind: account.kind, status },
|
|
267
|
+
});
|
|
268
|
+
return ok({ ...account, status });
|
|
269
|
+
}),
|
|
270
|
+
)
|
|
271
|
+
.post(
|
|
272
|
+
"/service-accounts/:id/api-keys",
|
|
273
|
+
describeRoute({ tags: ["Service accounts"], summary: "Create an API key for a standalone service account; return its token once" }),
|
|
274
|
+
v("param", ServiceAccountParamsSchema),
|
|
275
|
+
v("json", ServiceAccountApiKeyInputSchema),
|
|
276
|
+
async (c) =>
|
|
277
|
+
respond(
|
|
278
|
+
c,
|
|
279
|
+
// The raw token intentionally appears only in this one create response.
|
|
280
|
+
() =>
|
|
281
|
+
service.credentials.createStandaloneApiToken({
|
|
282
|
+
serviceAccountId: c.req.valid("param").id,
|
|
283
|
+
actor: c.get("user"),
|
|
284
|
+
...c.req.valid("json"),
|
|
285
|
+
}),
|
|
286
|
+
201,
|
|
287
|
+
),
|
|
288
|
+
)
|
|
161
289
|
.get("/mandates", v("query", MandateListQuerySchema), async (c) => {
|
|
162
290
|
const query = c.req.valid("query");
|
|
163
291
|
return c.json(
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { requireCliPluginAccess } from "../_internal/cli-plugins";
|
|
3
|
+
import { listApps } from "../_internal/registry";
|
|
4
|
+
import type { CloudCliPluginSummary } from "../cli/plugin";
|
|
5
|
+
import type { AuthContext } from "../server/middleware/auth";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `GET /cli/plugins`: the `cld` plugins that live applications serve, under
|
|
9
|
+
* the same access rule as the plugin downloads. Core mounts this list; each
|
|
10
|
+
* application serves its own plugin files.
|
|
11
|
+
*/
|
|
12
|
+
export const cliPluginListRoutes = new Hono<AuthContext>().get("/", requireCliPluginAccess, async (c) => {
|
|
13
|
+
const plugins: CloudCliPluginSummary[] = (await listApps())
|
|
14
|
+
.flatMap((app) => (app.cliModules ?? []).map((name) => ({ name, app: app.id, version: app.runtime?.version ?? "unknown" })))
|
|
15
|
+
.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
|
|
16
|
+
return c.json({ plugins }, 200, { "cache-control": "private, no-store" });
|
|
17
|
+
});
|
package/src/api/index.ts
CHANGED
|
@@ -43,6 +43,7 @@ import { createSyncOpsProxyRoutes } from "./sync-ops";
|
|
|
43
43
|
import { createWidgetRoutes } from "./widgets";
|
|
44
44
|
|
|
45
45
|
export { type CapabilityDispatchDependencies, dispatchCapability } from "./capabilities";
|
|
46
|
+
export { cliPluginListRoutes } from "./cli-plugins";
|
|
46
47
|
|
|
47
48
|
/**
|
|
48
49
|
* Single-expression chain so `typeof buildCoreApi()` captures every route's
|
package/src/cli/access.ts
CHANGED
|
@@ -234,9 +234,17 @@ export const resolveAccessPrincipal = async (
|
|
|
234
234
|
return resolveEntityPrincipal(ctx, "service_account", flags.serviceAccount!);
|
|
235
235
|
};
|
|
236
236
|
|
|
237
|
+
const entryTypeLabel = (entry: AccessEntry): string => {
|
|
238
|
+
if (entry.principal.type !== "service_account") return entry.principal.type;
|
|
239
|
+
if (entry.serviceAccountKind === "agent") return "agent";
|
|
240
|
+
if (entry.serviceAccountKind === "resource_bound") return "resource-bound";
|
|
241
|
+
return "service account";
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// Resource-bound service accounts back resource API keys, which are managed with those keys.
|
|
237
245
|
const accessRows = (entries: AccessEntry[], options: { includeServiceAccounts?: boolean } = {}) =>
|
|
238
246
|
entries
|
|
239
|
-
.filter((entry) => options.includeServiceAccounts || entry.
|
|
247
|
+
.filter((entry) => options.includeServiceAccounts || entry.serviceAccountKind !== "resource_bound")
|
|
240
248
|
.sort((a, b) => {
|
|
241
249
|
const rank = permissionRank(b.permission) - permissionRank(a.permission);
|
|
242
250
|
if (rank !== 0) return rank;
|
|
@@ -245,7 +253,7 @@ const accessRows = (entries: AccessEntry[], options: { includeServiceAccounts?:
|
|
|
245
253
|
.map((entry) => ({
|
|
246
254
|
accessId: entry.id,
|
|
247
255
|
principal: entryDisplayName(entry),
|
|
248
|
-
type: entry
|
|
256
|
+
type: entryTypeLabel(entry),
|
|
249
257
|
permission: entry.permission,
|
|
250
258
|
createdAt: entry.createdAt,
|
|
251
259
|
}));
|
|
@@ -355,7 +363,7 @@ export const createAccessCommands = <TResource extends AccessResource>(adapter:
|
|
|
355
363
|
flags: {
|
|
356
364
|
includeServiceAccounts: flag.boolean({
|
|
357
365
|
name: "include-service-accounts",
|
|
358
|
-
description: "
|
|
366
|
+
description: "Also show grants of resource-bound service accounts (resource API keys).",
|
|
359
367
|
}),
|
|
360
368
|
},
|
|
361
369
|
examples: adapter.examples?.list,
|
package/src/cli/account.ts
CHANGED
|
@@ -224,6 +224,12 @@ const readRequiredSecret = async (input: CliInputFlagValue, label: string): Prom
|
|
|
224
224
|
export default defineCliCommands({
|
|
225
225
|
name: "account",
|
|
226
226
|
summary: "Manage the authenticated account, profile, personal API keys, and SSH keys.",
|
|
227
|
+
groupSummaries: {
|
|
228
|
+
profile: "Show and update the account profile",
|
|
229
|
+
"api-keys": "Manage personal API keys",
|
|
230
|
+
"ssh-keys": "Manage SSH public keys",
|
|
231
|
+
password: "Change the account password",
|
|
232
|
+
},
|
|
227
233
|
commands: [
|
|
228
234
|
command("whoami", {
|
|
229
235
|
summary: "Show the authenticated account",
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cld admin agents` — standalone service accounts of kind `agent` with an
|
|
3
|
+
* OAuth client. `create` provisions the account and the client under the
|
|
4
|
+
* administrator's authority and writes the client credentials into a local
|
|
5
|
+
* `cld` profile through the host; the secret is never printed.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { arg, cliText, command, confirmFlag, flag, printStructured } from "../index";
|
|
9
|
+
import { apiGet, apiJson, queryString } from "./shared";
|
|
10
|
+
|
|
11
|
+
const accountsRoot = "/api/admin/identity/service-accounts";
|
|
12
|
+
const clientsRoot = "/api/oauth/admin/clients";
|
|
13
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
14
|
+
|
|
15
|
+
/** The scopes a `cld login` device session receives; an agent may do what a signed-in person's `cld` may do. */
|
|
16
|
+
export const AGENT_CLIENT_SCOPES = ["openid", "profile", "email", "offline_access", "read", "write"] as const;
|
|
17
|
+
|
|
18
|
+
const account = z.object({
|
|
19
|
+
id: z.string(),
|
|
20
|
+
name: z.string(),
|
|
21
|
+
kind: z.string(),
|
|
22
|
+
status: z.enum(["active", "disabled"]),
|
|
23
|
+
createdBy: z.string().nullable(),
|
|
24
|
+
createdAt: z.string(),
|
|
25
|
+
});
|
|
26
|
+
type Account = z.infer<typeof account>;
|
|
27
|
+
const accountPage = z.object({ items: z.array(account), page: z.number(), perPage: z.number(), total: z.number(), hasNext: z.boolean() });
|
|
28
|
+
const client = z.object({ id: z.string(), clientId: z.string(), name: z.string(), scopes: z.array(z.string()) });
|
|
29
|
+
const clientPage = z.object({ clients: z.array(client) });
|
|
30
|
+
const createdClient = client.extend({ clientSecret: z.string().min(1) });
|
|
31
|
+
const rotatedSecret = z.object({ clientSecret: z.string().min(1) });
|
|
32
|
+
|
|
33
|
+
type Ctx = Parameters<Parameters<typeof command>[1]["run"]>[0]["ctx"];
|
|
34
|
+
|
|
35
|
+
const requireProfiles = (ctx: Ctx) => {
|
|
36
|
+
if (!ctx.profiles) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
cliText(ctx, {
|
|
39
|
+
en: "This cld cannot store agent profiles. Update cld and retry.",
|
|
40
|
+
de: "Dieses cld kann keine Agent-Profile speichern. Aktualisiere cld und versuche es erneut.",
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return ctx.profiles;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const requireYes = (ctx: Ctx, yes: boolean | undefined, en: string, de: string) => {
|
|
48
|
+
if (!yes) throw new Error(cliText(ctx, { en, de }));
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Resolve an agent by ID or exact name; ambiguity is an error, nothing is guessed. */
|
|
52
|
+
const resolveAgent = async (ctx: Ctx, reference: string): Promise<Account> => {
|
|
53
|
+
if (UUID_PATTERN.test(reference)) {
|
|
54
|
+
const found = account.parse(await apiGet(ctx, `${accountsRoot}/${encodeURIComponent(reference)}`));
|
|
55
|
+
if (found.kind !== "agent") throw new Error(`Service account ${reference} is not an agent (kind ${found.kind}).`);
|
|
56
|
+
return found;
|
|
57
|
+
}
|
|
58
|
+
const page = accountPage.parse(await apiGet(ctx, `${accountsRoot}${queryString({ kind: "agent", search: reference, perPage: 50 })}`));
|
|
59
|
+
const matches = page.items.filter((item) => item.name.toLowerCase() === reference.toLowerCase());
|
|
60
|
+
if (matches.length === 1) return matches[0]!;
|
|
61
|
+
if (matches.length === 0) throw new Error(`No agent named "${reference}". Run \`cld admin agents ls\`.`);
|
|
62
|
+
throw new Error(`Several agents match "${reference}": ${matches.map((item) => `${item.name} (${item.id})`).join(", ")}.`);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const disableAccount = (ctx: Ctx, id: string) => apiJson(ctx, "PATCH", `${accountsRoot}/${encodeURIComponent(id)}`, { status: "disabled" });
|
|
66
|
+
|
|
67
|
+
const requireProfileName = (flags: { profile?: string }): string => {
|
|
68
|
+
if (!flags.profile) throw new Error("Missing required flag --profile.");
|
|
69
|
+
return flags.profile;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const fd0Target = (flags: { fd0?: boolean; fd0Name?: string; fd0Scope?: string }, profile: string) =>
|
|
73
|
+
flags.fd0 || flags.fd0Name
|
|
74
|
+
? { name: flags.fd0Name ?? `cloud-${profile}-oauth-client-secret`, ...(flags.fd0Scope ? { scope: flags.fd0Scope } : {}) }
|
|
75
|
+
: undefined;
|
|
76
|
+
|
|
77
|
+
const profileFlags = {
|
|
78
|
+
profile: flag.string({ required: true, description: "Local cld profile that receives the client credentials" }),
|
|
79
|
+
server: flag.string({ description: "Cloud origin for that profile (default: the current server)" }),
|
|
80
|
+
fd0: flag.boolean({ description: "Keep the client secret in fd0 instead of the config file" }),
|
|
81
|
+
fd0Name: flag.string({ name: "fd0-name", description: "fd0 secret name (default: cloud-<profile>-oauth-client-secret)" }),
|
|
82
|
+
fd0Scope: flag.string({ name: "fd0-scope", description: "fd0 scope for the secret" }),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const agentCommands = [
|
|
86
|
+
command("agents create", {
|
|
87
|
+
summary: "Create an agent account with an OAuth client and write its credentials into a local cld profile",
|
|
88
|
+
args: { name: arg.required({ valueLabel: "name" }) },
|
|
89
|
+
flags: { ...profileFlags, yes: confirmFlag("Create this agent and its OAuth client") },
|
|
90
|
+
async run({ ctx, args, flags }) {
|
|
91
|
+
requireYes(ctx, flags.yes, "Creating an agent requires --yes.", "Agent erstellen erfordert --yes.");
|
|
92
|
+
const profileName = requireProfileName(flags);
|
|
93
|
+
const profiles = requireProfiles(ctx);
|
|
94
|
+
const server = flags.server ?? ctx.options.server;
|
|
95
|
+
const created = account.parse(await apiJson(ctx, "POST", accountsRoot, { name: args.name, kind: "agent" }));
|
|
96
|
+
|
|
97
|
+
let oauthClient: z.infer<typeof createdClient>;
|
|
98
|
+
try {
|
|
99
|
+
oauthClient = createdClient.parse(
|
|
100
|
+
await apiJson(ctx, "POST", clientsRoot, {
|
|
101
|
+
name: `Agent: ${args.name}`,
|
|
102
|
+
description: `Client credentials of the agent "${args.name}" (cld admin agents).`,
|
|
103
|
+
redirectUris: [],
|
|
104
|
+
scopes: [...AGENT_CLIENT_SCOPES],
|
|
105
|
+
audiences: ["cloud"],
|
|
106
|
+
serviceAccountId: created.id,
|
|
107
|
+
allowedProfiles: [],
|
|
108
|
+
accessMode: "profiles",
|
|
109
|
+
allowedUserIds: [],
|
|
110
|
+
allowedGroupIds: [],
|
|
111
|
+
isPublic: false,
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
await disableAccount(ctx, created.id).catch(() => undefined);
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Created the agent account ${created.id} but not its OAuth client; the account was disabled. ${(error as Error).message}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
await profiles.saveClientCredentials({
|
|
123
|
+
name: profileName,
|
|
124
|
+
server,
|
|
125
|
+
clientId: oauthClient.clientId,
|
|
126
|
+
clientSecret: oauthClient.clientSecret,
|
|
127
|
+
scope: oauthClient.scopes.join(" "),
|
|
128
|
+
fd0: fd0Target(flags, profileName),
|
|
129
|
+
});
|
|
130
|
+
} catch (error) {
|
|
131
|
+
await disableAccount(ctx, created.id).catch(() => undefined);
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Could not save profile "${profileName}"; the agent account ${created.id} was disabled. ${(error as Error).message}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const summary = {
|
|
138
|
+
account: created,
|
|
139
|
+
client: { id: oauthClient.id, clientId: oauthClient.clientId, scopes: oauthClient.scopes },
|
|
140
|
+
profile: { name: profileName, server, secretStorage: fd0Target(flags, profileName) ? "fd0" : "config" },
|
|
141
|
+
};
|
|
142
|
+
if (!printStructured(ctx, summary)) {
|
|
143
|
+
ctx.print(`Created agent "${created.name}" (${created.id}) with OAuth client ${oauthClient.clientId}.`);
|
|
144
|
+
ctx.print(`Profile "${profileName}" holds its client credentials. The agent has no access yet; grant it like a user.`);
|
|
145
|
+
ctx.print(`Try: cld --profile ${profileName} account whoami`);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
}),
|
|
149
|
+
command("agents ls", {
|
|
150
|
+
summary: "List agent accounts",
|
|
151
|
+
flags: {
|
|
152
|
+
status: flag.enum(["active", "disabled"] as const),
|
|
153
|
+
search: flag.string({ aliases: ["q"] }),
|
|
154
|
+
page: flag.int({ min: 1, default: 1 }),
|
|
155
|
+
perPage: flag.int({ min: 1, max: 500, default: 100 }),
|
|
156
|
+
},
|
|
157
|
+
async run({ ctx, flags }) {
|
|
158
|
+
const page = accountPage.parse(
|
|
159
|
+
await apiGet(
|
|
160
|
+
ctx,
|
|
161
|
+
`${accountsRoot}${queryString({ kind: "agent", status: flags.status, search: flags.search, page: flags.page, perPage: flags.perPage })}`,
|
|
162
|
+
),
|
|
163
|
+
);
|
|
164
|
+
if (!printStructured(ctx, page))
|
|
165
|
+
ctx.table(page.items, [
|
|
166
|
+
{ key: "status", label: "Status" },
|
|
167
|
+
{ key: "name", label: "Name" },
|
|
168
|
+
{ key: "id", label: "ID" },
|
|
169
|
+
{ key: "createdAt", label: "Created" },
|
|
170
|
+
]);
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
command("agents revoke", {
|
|
174
|
+
summary: "Disable an agent; every token, secret, and API key of it stops working at once",
|
|
175
|
+
args: { agent: arg.required({ valueLabel: "agent-id-or-name" }) },
|
|
176
|
+
flags: { yes: confirmFlag("Disable this agent") },
|
|
177
|
+
async run({ ctx, args, flags }) {
|
|
178
|
+
requireYes(ctx, flags.yes, "Revoking an agent requires --yes.", "Agent widerrufen erfordert --yes.");
|
|
179
|
+
const agent = await resolveAgent(ctx, args.agent);
|
|
180
|
+
const updated = account.parse(await disableAccount(ctx, agent.id));
|
|
181
|
+
if (!printStructured(ctx, updated)) ctx.print(`Disabled agent "${updated.name}" (${updated.id}).`);
|
|
182
|
+
},
|
|
183
|
+
}),
|
|
184
|
+
command("agents rotate-secret", {
|
|
185
|
+
summary: "Regenerate an agent's OAuth client secret and update the local cld profile",
|
|
186
|
+
args: { agent: arg.required({ valueLabel: "agent-id-or-name" }) },
|
|
187
|
+
flags: { ...profileFlags, yes: confirmFlag("Rotate this agent's client secret") },
|
|
188
|
+
async run({ ctx, args, flags }) {
|
|
189
|
+
requireYes(ctx, flags.yes, "Rotating a secret requires --yes.", "Secret rotieren erfordert --yes.");
|
|
190
|
+
const profileName = requireProfileName(flags);
|
|
191
|
+
const profiles = requireProfiles(ctx);
|
|
192
|
+
const agent = await resolveAgent(ctx, args.agent);
|
|
193
|
+
const { clients } = clientPage.parse(await apiGet(ctx, `${clientsRoot}${queryString({ serviceAccountId: agent.id, per_page: 50 })}`));
|
|
194
|
+
if (clients.length !== 1) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
clients.length === 0
|
|
197
|
+
? `Agent "${agent.name}" has no OAuth client.`
|
|
198
|
+
: `Agent "${agent.name}" has several OAuth clients: ${clients.map((item) => item.clientId).join(", ")}. Rotate one with cld oauth clients regenerate-secret.`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const oauthClient = clients[0]!;
|
|
202
|
+
const rotated = rotatedSecret.parse(
|
|
203
|
+
await apiJson(ctx, "POST", `${clientsRoot}/${encodeURIComponent(oauthClient.id)}/regenerate-secret`),
|
|
204
|
+
);
|
|
205
|
+
await profiles.saveClientCredentials({
|
|
206
|
+
name: profileName,
|
|
207
|
+
server: flags.server ?? ctx.options.server,
|
|
208
|
+
clientId: oauthClient.clientId,
|
|
209
|
+
clientSecret: rotated.clientSecret,
|
|
210
|
+
scope: oauthClient.scopes.join(" "),
|
|
211
|
+
fd0: fd0Target(flags, profileName),
|
|
212
|
+
});
|
|
213
|
+
const summary = { account: agent, client: { id: oauthClient.id, clientId: oauthClient.clientId }, profile: profileName };
|
|
214
|
+
if (!printStructured(ctx, summary)) {
|
|
215
|
+
ctx.print(`Rotated the client secret of agent "${agent.name}"; profile "${profileName}" holds the new secret.`);
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
}),
|
|
219
|
+
];
|
package/src/cli/admin/index.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { defineCliCommands } from "../commands";
|
|
9
9
|
import { accountAdministrationCommands } from "./account-administration";
|
|
10
10
|
import { accountCategoryCommands } from "./account-categories";
|
|
11
|
+
import { agentCommands } from "./agents";
|
|
11
12
|
import { aiQuotaCommands } from "./ai-quotas";
|
|
12
13
|
import { aiSkillCommands } from "./ai-skills";
|
|
13
14
|
import { aiUsageCommands } from "./ai-usage";
|
|
@@ -33,7 +34,39 @@ export default defineCliCommands({
|
|
|
33
34
|
name: "admin",
|
|
34
35
|
summary: "Inspect and operate Cloud administration surfaces.",
|
|
35
36
|
groupSummaries: {
|
|
37
|
+
accounts: "Manage account policy and account administration.",
|
|
38
|
+
ai: "Manage AI models, quotas, Skills, and usage.",
|
|
39
|
+
"ai models": "Manage AI model configuration.",
|
|
40
|
+
"ai models pricing": "Manage model reference prices.",
|
|
41
|
+
"ai quotas background": "Inspect and release the background emergency stop.",
|
|
42
|
+
"ai skills": "Inspect Assistant Skills and trusted templates.",
|
|
43
|
+
"ai usage": "Inspect AI runs and usage facets.",
|
|
44
|
+
announcements: "Manage platform announcements and banners.",
|
|
36
45
|
"app-credentials": "Manage per-application credentials for background work.",
|
|
46
|
+
agents: "Create, list, revoke, and re-key agent accounts with OAuth client credentials.",
|
|
47
|
+
"app-sign-in": "Manage app sign-in and pairing.",
|
|
48
|
+
apps: "Inspect and remove registered apps.",
|
|
49
|
+
documentation: "Manage the administration documentation website.",
|
|
50
|
+
jobs: "Inspect background jobs and their runs.",
|
|
51
|
+
logs: "Search, inspect, and clean up retained logs.",
|
|
52
|
+
metrics: "Inspect collectors and manage metrics access.",
|
|
53
|
+
"metrics tokens": "Manage metrics bearer tokens.",
|
|
54
|
+
nats: "Inspect NATS nodes, streams, and consumers.",
|
|
55
|
+
"nats consumers": "Inspect NATS consumers.",
|
|
56
|
+
"nats streams": "Find NATS streams and buckets.",
|
|
57
|
+
"notification-batches": "Manage account notification batches.",
|
|
58
|
+
notifications: "Inspect and resend email notifications.",
|
|
59
|
+
postgres: "Inspect Postgres diagnostics.",
|
|
60
|
+
redis: "Inspect Valkey diagnostics.",
|
|
61
|
+
routes: "Inspect gateway routes.",
|
|
62
|
+
sync: "Inspect Sync resources, schedules, and dead letters.",
|
|
63
|
+
"sync dead-letters": "Inspect, replay, requeue, or remove dead letters.",
|
|
64
|
+
"sync resources": "List Sync resource declarations.",
|
|
65
|
+
"sync schedules": "Inspect and run Sync schedules.",
|
|
66
|
+
"sync schedules runs": "Read Sync schedule run results.",
|
|
67
|
+
telemetry: "Inspect request telemetry.",
|
|
68
|
+
webhooks: "Manage gateway health webhooks.",
|
|
69
|
+
workflows: "Inspect and operate workflow runs and effects.",
|
|
37
70
|
"app-sign-in config": "Manage the trusted app website and pairing policy.",
|
|
38
71
|
"accounts config": "Manage account types and sign-in visibility.",
|
|
39
72
|
"accounts administration": "Manage account requests and account/group follow-up notices.",
|
|
@@ -45,6 +78,7 @@ export default defineCliCommands({
|
|
|
45
78
|
},
|
|
46
79
|
commands: [
|
|
47
80
|
...appCredentialCommands,
|
|
81
|
+
...agentCommands,
|
|
48
82
|
...documentationCommands,
|
|
49
83
|
...appSignInCommands,
|
|
50
84
|
...accountCategoryCommands,
|
package/src/cli/index.ts
CHANGED
|
@@ -43,6 +43,28 @@ export type CloudCliContext = {
|
|
|
43
43
|
/** Print one compact JSON value followed by a newline. */
|
|
44
44
|
jsonLine: (value: unknown) => void;
|
|
45
45
|
table: <TRow extends Record<string, unknown>>(rows: TRow[], columns: CloudCliTableColumn<TRow>[]) => void;
|
|
46
|
+
/**
|
|
47
|
+
* The host's local profile store, when it has one. A command that provisions
|
|
48
|
+
* a machine credential hands the secret here instead of printing it; the host
|
|
49
|
+
* stores it like a refresh token. Absent in hosts without profiles.
|
|
50
|
+
*/
|
|
51
|
+
profiles?: {
|
|
52
|
+
saveClientCredentials: (input: CloudCliClientCredentialsProfile) => Promise<void>;
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** A profile that signs in with the OAuth client-credentials grant. */
|
|
57
|
+
export type CloudCliClientCredentialsProfile = {
|
|
58
|
+
/** Profile name in the host's config. */
|
|
59
|
+
name: string;
|
|
60
|
+
/** Cloud origin the credentials belong to. */
|
|
61
|
+
server: string;
|
|
62
|
+
clientId: string;
|
|
63
|
+
clientSecret: string;
|
|
64
|
+
/** Space-separated scopes the client is allowed to request. */
|
|
65
|
+
scope: string;
|
|
66
|
+
/** Keep the secret in fd0 under this name instead of the config file. */
|
|
67
|
+
fd0?: { name: string; scope?: string };
|
|
46
68
|
};
|
|
47
69
|
|
|
48
70
|
export type CloudCliModule = {
|
|
@@ -61,3 +83,4 @@ export * from "./address";
|
|
|
61
83
|
export * from "./commands";
|
|
62
84
|
export * from "./locale";
|
|
63
85
|
export * from "./output";
|
|
86
|
+
export * from "./plugin";
|