@k2b/cloud 0.45.0 → 0.47.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 +11 -4
- package/scripts/build.ts +5 -1
- package/scripts/preload.ts +5 -1
- package/src/_internal/app-queries.ts +73 -0
- package/src/_internal/capabilities.ts +137 -9
- package/src/_internal/define-app.ts +40 -0
- package/src/_internal/heartbeat.ts +19 -0
- package/src/_internal/postgres-application-name.ts +11 -0
- package/src/_internal/process-sync.ts +9 -0
- package/src/_internal/readiness.ts +6 -1
- package/src/_internal/registry-validation.ts +17 -4
- package/src/_internal/registry.ts +32 -4
- package/src/_internal/resource-principals.ts +6 -0
- package/src/_internal/workload-credential.ts +153 -0
- package/src/_internal/workload-protocol.ts +36 -0
- package/src/ai/application-tasks.ts +5 -2
- package/src/ai/capabilities.ts +4 -2
- package/src/ai/chart-block.ts +36 -711
- package/src/ai/chat/map-messages.ts +270 -0
- package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
- package/src/ai/chat/map-notebook.tsx +301 -0
- package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
- package/src/ai/chat/map-widget.css +107 -0
- package/src/ai/chat/map-widget.tsx +1279 -0
- package/src/ai/chat/message-utils.ts +1 -0
- package/src/ai/chat/tool-names.ts +2 -0
- package/src/ai/chat/turn-layout.ts +2 -1
- package/src/ai/chat/turn-view.tsx +17 -1
- package/src/ai/default-tool-contracts.ts +21 -259
- package/src/ai/default-tools.ts +3 -0
- package/src/ai/executor.ts +13 -1
- package/src/ai/map-contracts.ts +209 -0
- package/src/ai/map-execution.ts +14 -0
- package/src/ai/map-notebook-save.ts +276 -0
- package/src/ai/map-notebook.ts +32 -0
- package/src/ai/map-opening-hours.ts +40 -0
- package/src/ai/map-routes.ts +84 -0
- package/src/ai/map-service.ts +288 -0
- package/src/ai/map-store.ts +137 -0
- package/src/ai/map-tool.ts +73 -0
- package/src/ai/migrate.ts +3 -0
- package/src/ai/protocol.ts +3 -1
- package/src/ai/routes.ts +2 -0
- package/src/ai/store.ts +19 -6
- package/src/ai/tools.ts +14 -0
- package/src/ai/types.ts +3 -0
- package/src/api/admin-app-keys.ts +66 -0
- package/src/api/admin-core-settings.ts +2 -0
- package/src/api/admin-identity.ts +27 -1
- package/src/api/admin-settings-env.ts +120 -0
- package/src/api/capabilities.ts +99 -15
- package/src/api/index.ts +3 -0
- package/src/api/mcp.ts +4 -2
- package/src/api/settings-ops.ts +35 -0
- package/src/app-key.ts +125 -0
- package/src/browser/CloudResourceSearch.tsx +48 -41
- package/src/browser/notifications.ts +25 -8
- package/src/capabilities/discussion-providers.ts +100 -0
- package/src/capabilities/executions.ts +2 -2
- package/src/capabilities/migrate.ts +10 -1
- package/src/capabilities/resource-readers.ts +69 -0
- package/src/capabilities/response.ts +4 -1
- package/src/capabilities/server-invoke.ts +64 -36
- package/src/capabilities/server.ts +17 -6
- package/src/capabilities/testing.ts +13 -0
- package/src/capabilities/types.ts +1 -1
- package/src/cli/admin/app-credentials.ts +32 -7
- package/src/cli/admin/app-keys.ts +226 -0
- package/src/cli/admin/index.ts +7 -1
- package/src/cli/admin/settings.ts +62 -0
- package/src/config/define-env.ts +63 -8
- package/src/config/env.ts +27 -21
- package/src/config/index.ts +12 -1
- package/src/config/workflow-ai.ts +28 -0
- package/src/contracts/capabilities.ts +137 -4
- package/src/contracts/discussions.ts +91 -0
- package/src/contracts/geo-document.ts +60 -0
- package/src/contracts/geo-snapshot.ts +86 -0
- package/src/contracts/geo.ts +14 -0
- package/src/contracts/index.ts +2 -0
- package/src/contracts/outgoing-mail.ts +1 -1
- package/src/contracts/resource-types.ts +21 -0
- package/src/contracts/settings-env.ts +21 -0
- package/src/contracts/settings-types.ts +6 -0
- package/src/geo/AddressAutocomplete.tsx +213 -0
- package/src/geo/messages.ts +29 -0
- package/src/geo/navigation.ts +25 -0
- package/src/geo/ui.ts +2 -0
- package/src/index.ts +9 -0
- package/src/runtime/clients.ts +72 -0
- package/src/runtime/connections.ts +37 -0
- package/src/runtime/resolve-connections.ts +91 -0
- package/src/server/client-ip.ts +1 -1
- package/src/services/accounts/app.ts +28 -6
- package/src/services/accounts/entities.ts +11 -4
- package/src/services/accounts/groups.ts +10 -4
- package/src/services/accounts/local-groups.ts +87 -38
- package/src/services/accounts/machine-group-management.ts +94 -0
- package/src/services/app-keys.ts +298 -0
- package/src/services/gateway.ts +6 -0
- package/src/services/geo/cache.ts +2 -1
- package/src/services/geo/geoapify.ts +3 -0
- package/src/services/identity/index.ts +3 -0
- package/src/services/identity/invocation-token.ts +110 -0
- package/src/services/identity/resource-readers.ts +80 -0
- package/src/services/identity/runtime-config.ts +14 -6
- package/src/services/identity/workload-runtime.ts +253 -0
- package/src/services/index.ts +4 -1
- package/src/services/outgoing-mail/resolve-host.ts +5 -4
- package/src/services/pdf/geo-snapshot.ts +107 -0
- package/src/services/pdf/index.ts +2 -0
- package/src/services/service-account-credentials.ts +63 -21
- package/src/services/settings/api.ts +3 -3
- package/src/services/settings/binding.ts +45 -0
- package/src/services/settings/core-settings.ts +99 -1
- package/src/services/settings/defaults.ts +16 -2
- package/src/services/settings/env-status.ts +69 -0
- package/src/services/settings/index.ts +69 -26
- package/src/services/settings/store.ts +73 -8
- package/src/services/settings/validation.ts +40 -0
- package/src/shared/index.ts +11 -10
- package/src/shared/markdown/extensions/tables.ts +10 -1
- package/src/ssr/AdminArea.tsx +2 -2
- package/src/ssr/AdminLayout.tsx +1 -1
- package/src/ssr/AdminSidebar.tsx +1 -1
- package/src/start.ts +36 -0
- package/src/styles/global.css +2 -0
- package/src/workflows/ai/index.ts +1 -0
- package/src/workflows/ai/runtime.ts +16 -8
- package/src/workflows/ai/settings.ts +5 -0
- package/src/shared/markdown/formula.ts +0 -1283
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { envSource } from "../config/define-env";
|
|
3
|
+
import { env } from "../config/env";
|
|
4
|
+
import type { ConnectionSource, PlatformConnections, PlatformConnectionValues } from "./connections";
|
|
5
|
+
|
|
6
|
+
type EnvKey = keyof typeof env.$specs;
|
|
7
|
+
const explicitlySet = (key: EnvKey): boolean => ["env", "alias", "file"].includes(envSource(env, key));
|
|
8
|
+
|
|
9
|
+
/** URL diagnostics deliberately omit both the input and underlying parser errors. */
|
|
10
|
+
const url = (key: string, value: string | undefined, bareHost = false): string => {
|
|
11
|
+
if (!value) throw new Error(`${key} is required for platform start`);
|
|
12
|
+
try {
|
|
13
|
+
const parsed = new URL(bareHost && !value.includes("://") ? `nats://${value}` : value);
|
|
14
|
+
if (bareHost && !parsed.hostname) throw new Error();
|
|
15
|
+
} catch {
|
|
16
|
+
throw new Error(`${key} must be a valid URL${bareHost ? " or host:port" : ""}`);
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
};
|
|
20
|
+
const contents = (key: "NATS_CREDS_FILE" | "NATS_TLS_CA_FILE"): string | undefined => {
|
|
21
|
+
const path = env[key];
|
|
22
|
+
if (!path) return undefined;
|
|
23
|
+
try {
|
|
24
|
+
const value = readFileSync(path, "utf8");
|
|
25
|
+
if (!value.trim()) throw new Error();
|
|
26
|
+
return value;
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error(`${key} must name a readable, non-empty file`);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Explicit environment values always win, including aliases, files and false.
|
|
34
|
+
* The Core bootstrap client supplies `core` in key mode; required values then
|
|
35
|
+
* never fall through to environment defaults. Optional values may use defaults.
|
|
36
|
+
*/
|
|
37
|
+
export const resolveConnections = ({ appId, core }: { appId: string; core?: PlatformConnectionValues }): PlatformConnections => {
|
|
38
|
+
const source = (key: EnvKey): ConnectionSource => (!core || explicitlySet(key) ? "env" : "core");
|
|
39
|
+
const choose = <T>(key: EnvKey, fromEnv: () => T, fromCore: T): T => (!core || explicitlySet(key) ? fromEnv() : fromCore);
|
|
40
|
+
// Keep value reads lazy: an absent env value must not evaluate its default in key mode.
|
|
41
|
+
const database = url(
|
|
42
|
+
"DATABASE_URL",
|
|
43
|
+
choose("DATABASE_URL", () => env.DATABASE_URL, core?.database.url),
|
|
44
|
+
);
|
|
45
|
+
const valkey = url(
|
|
46
|
+
"REDIS_URL",
|
|
47
|
+
choose("REDIS_URL", () => env.REDIS_URL, core?.valkey.url),
|
|
48
|
+
);
|
|
49
|
+
const servers = choose("NATS_SERVERS", () => env.NATS_SERVERS, core?.nats.servers) ?? [];
|
|
50
|
+
for (const server of servers) url("NATS_SERVERS", server, true);
|
|
51
|
+
const namespace = choose("SYNC_NAMESPACE", () => env.SYNC_NAMESPACE, core?.nats.namespace) ?? "";
|
|
52
|
+
const replicas = choose("SYNC_REPLICAS", () => env.SYNC_REPLICAS, core?.nats.replicas) ?? env.SYNC_REPLICAS;
|
|
53
|
+
const ignoreClusterUpdates =
|
|
54
|
+
choose("NATS_IGNORE_CLUSTER_UPDATES", () => env.NATS_IGNORE_CLUSTER_UPDATES, core?.nats.ignoreClusterUpdates) ??
|
|
55
|
+
env.NATS_IGNORE_CLUSTER_UPDATES;
|
|
56
|
+
const creds = choose("NATS_CREDS_FILE", () => contents("NATS_CREDS_FILE"), core?.nats.creds);
|
|
57
|
+
const ca = choose("NATS_TLS_CA_FILE", () => contents("NATS_TLS_CA_FILE"), core?.nats.ca);
|
|
58
|
+
const secret = choose("APP_SECRET", () => env.APP_SECRET, core?.appSecret.current) ?? "";
|
|
59
|
+
const rotation = core && !explicitlySet("APP_SECRET") ? core.appSecret : undefined;
|
|
60
|
+
if (core) {
|
|
61
|
+
if (servers.length === 0) throw new Error("NATS_SERVERS is required for platform start");
|
|
62
|
+
if (!namespace.trim()) throw new Error("SYNC_NAMESPACE is required for platform start");
|
|
63
|
+
if (!secret.trim()) throw new Error("APP_SECRET is required for platform start");
|
|
64
|
+
}
|
|
65
|
+
if (![1, 3, 5].includes(replicas)) throw new Error("SYNC_REPLICAS must be 1, 3, or 5");
|
|
66
|
+
if (typeof ignoreClusterUpdates !== "boolean") throw new Error("NATS_IGNORE_CLUSTER_UPDATES must be a boolean");
|
|
67
|
+
return {
|
|
68
|
+
appId,
|
|
69
|
+
database: { url: database },
|
|
70
|
+
valkey: { url: valkey },
|
|
71
|
+
nats: { servers: [...servers], namespace, replicas, ignoreClusterUpdates, creds, ca },
|
|
72
|
+
appSecret: { current: secret, previous: rotation?.previous, next: rotation?.next },
|
|
73
|
+
sources: {
|
|
74
|
+
database: { url: source("DATABASE_URL") },
|
|
75
|
+
valkey: { url: source("REDIS_URL") },
|
|
76
|
+
nats: {
|
|
77
|
+
servers: source("NATS_SERVERS"),
|
|
78
|
+
namespace: source("SYNC_NAMESPACE"),
|
|
79
|
+
replicas: source("SYNC_REPLICAS"),
|
|
80
|
+
ignoreClusterUpdates: source("NATS_IGNORE_CLUSTER_UPDATES"),
|
|
81
|
+
creds: source("NATS_CREDS_FILE"),
|
|
82
|
+
ca: source("NATS_TLS_CA_FILE"),
|
|
83
|
+
},
|
|
84
|
+
appSecret: {
|
|
85
|
+
current: source("APP_SECRET"),
|
|
86
|
+
...(rotation?.previous ? { previous: "core" as const } : {}),
|
|
87
|
+
...(rotation?.next ? { next: "core" as const } : {}),
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
};
|
package/src/server/client-ip.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isIP } from "node:net";
|
|
2
2
|
import type { Context } from "hono";
|
|
3
3
|
|
|
4
|
-
// The gateway (packages/gateway/src/client-address.ts) resolves this address using
|
|
4
|
+
// The gateway (packages/gateway/src/client-address.ts) resolves this address using the gateway.trusted_proxies setting.
|
|
5
5
|
export const clientIp = (c: Context): string =>
|
|
6
6
|
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? c.req.header("x-real-ip") ?? "unknown";
|
|
7
7
|
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
UserProfile,
|
|
15
15
|
UserProvider,
|
|
16
16
|
} from "../../contracts/shared";
|
|
17
|
+
import type { ServiceAccountRequestActor } from "../../server/middleware/auth";
|
|
17
18
|
import {
|
|
18
19
|
err,
|
|
19
20
|
fail,
|
|
@@ -41,6 +42,7 @@ import type { AvatarChange, AvatarState } from "./avatar";
|
|
|
41
42
|
import { listDuplicateEmails } from "./duplicate-emails";
|
|
42
43
|
import * as entities from "./entities";
|
|
43
44
|
import * as groups from "./groups";
|
|
45
|
+
import { mutateMachineGroupMember } from "./machine-group-management";
|
|
44
46
|
import type { AccountsNotificationSender } from "./notification-sender";
|
|
45
47
|
import { PosixError } from "./posix";
|
|
46
48
|
import { accountRequestsEnabled } from "./request-policy";
|
|
@@ -1018,13 +1020,15 @@ export const accountsAppService = {
|
|
|
1018
1020
|
return paginateItems(filtered, config.pagination);
|
|
1019
1021
|
},
|
|
1020
1022
|
add: async (config: {
|
|
1021
|
-
actor: AccountsActor;
|
|
1023
|
+
actor: AccountsActor | ServiceAccountRequestActor;
|
|
1024
|
+
requestId?: string;
|
|
1022
1025
|
id: string;
|
|
1023
1026
|
provider?: UserProvider;
|
|
1024
1027
|
userId?: string;
|
|
1025
1028
|
groupId?: string;
|
|
1026
1029
|
serviceAccountId?: string;
|
|
1027
1030
|
}) => {
|
|
1031
|
+
if ("kind" in config.actor) return mutateMachineGroupMember({ ...config, actor: config.actor, operation: "add" });
|
|
1028
1032
|
const group = await groups.get({ id: config.id });
|
|
1029
1033
|
if (!group) {
|
|
1030
1034
|
const result = fail(err.notFound("Group not found"));
|
|
@@ -1055,13 +1059,15 @@ export const accountsAppService = {
|
|
|
1055
1059
|
});
|
|
1056
1060
|
},
|
|
1057
1061
|
remove: async (config: {
|
|
1058
|
-
actor: AccountsActor;
|
|
1062
|
+
actor: AccountsActor | ServiceAccountRequestActor;
|
|
1063
|
+
requestId?: string;
|
|
1059
1064
|
id: string;
|
|
1060
1065
|
provider?: UserProvider;
|
|
1061
1066
|
userId?: string;
|
|
1062
1067
|
groupId?: string;
|
|
1063
1068
|
serviceAccountId?: string;
|
|
1064
1069
|
}) => {
|
|
1070
|
+
if ("kind" in config.actor) return mutateMachineGroupMember({ ...config, actor: config.actor, operation: "remove" });
|
|
1065
1071
|
const group = await groups.get({ id: config.id });
|
|
1066
1072
|
if (!group) {
|
|
1067
1073
|
const result = fail(err.notFound("Group not found"));
|
|
@@ -1110,7 +1116,14 @@ export const accountsAppService = {
|
|
|
1110
1116
|
});
|
|
1111
1117
|
return paginateItems(filtered, config.pagination);
|
|
1112
1118
|
},
|
|
1113
|
-
add: async (config: {
|
|
1119
|
+
add: async (config: {
|
|
1120
|
+
actor: AccountsActor;
|
|
1121
|
+
id: string;
|
|
1122
|
+
provider?: UserProvider;
|
|
1123
|
+
userId?: string;
|
|
1124
|
+
groupId?: string;
|
|
1125
|
+
serviceAccountId?: string;
|
|
1126
|
+
}) => {
|
|
1114
1127
|
const group = await groups.get({ id: config.id });
|
|
1115
1128
|
if (!group) {
|
|
1116
1129
|
const result = fail(err.notFound("Group not found"));
|
|
@@ -1133,17 +1146,25 @@ export const accountsAppService = {
|
|
|
1133
1146
|
provider: config.provider,
|
|
1134
1147
|
user: config.userId,
|
|
1135
1148
|
group: config.groupId,
|
|
1149
|
+
serviceAccount: config.serviceAccountId,
|
|
1136
1150
|
}),
|
|
1137
1151
|
);
|
|
1138
1152
|
return recordCompletedMutation({
|
|
1139
1153
|
action: "accounts.group.manager.add",
|
|
1140
1154
|
actor: auditActor(config.actor),
|
|
1141
1155
|
target: groupTarget(group),
|
|
1142
|
-
metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
|
|
1156
|
+
metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null, serviceAccountId: config.serviceAccountId ?? null },
|
|
1143
1157
|
result,
|
|
1144
1158
|
});
|
|
1145
1159
|
},
|
|
1146
|
-
remove: async (config: {
|
|
1160
|
+
remove: async (config: {
|
|
1161
|
+
actor: AccountsActor;
|
|
1162
|
+
id: string;
|
|
1163
|
+
provider?: UserProvider;
|
|
1164
|
+
userId?: string;
|
|
1165
|
+
groupId?: string;
|
|
1166
|
+
serviceAccountId?: string;
|
|
1167
|
+
}) => {
|
|
1147
1168
|
const group = await groups.get({ id: config.id });
|
|
1148
1169
|
if (!group) {
|
|
1149
1170
|
const result = fail(err.notFound("Group not found"));
|
|
@@ -1166,13 +1187,14 @@ export const accountsAppService = {
|
|
|
1166
1187
|
provider: config.provider,
|
|
1167
1188
|
user: config.userId,
|
|
1168
1189
|
group: config.groupId,
|
|
1190
|
+
serviceAccount: config.serviceAccountId,
|
|
1169
1191
|
}),
|
|
1170
1192
|
);
|
|
1171
1193
|
return recordCompletedMutation({
|
|
1172
1194
|
action: "accounts.group.manager.remove",
|
|
1173
1195
|
actor: auditActor(config.actor),
|
|
1174
1196
|
target: groupTarget(group),
|
|
1175
|
-
metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
|
|
1197
|
+
metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null, serviceAccountId: config.serviceAccountId ?? null },
|
|
1176
1198
|
result,
|
|
1177
1199
|
});
|
|
1178
1200
|
},
|
|
@@ -484,8 +484,8 @@ export const list = async (
|
|
|
484
484
|
AND ug.group_id = ANY(${toPgUuidArray(params.userMemberOfGroupIds ?? [])}::uuid[])
|
|
485
485
|
))`;
|
|
486
486
|
|
|
487
|
-
// Machine
|
|
488
|
-
const otherRelation = Boolean(params.
|
|
487
|
+
// Machine management is direct only; membership never implies management.
|
|
488
|
+
const otherRelation = Boolean(params.parentGroupId || params.managedByUserId);
|
|
489
489
|
const machineRelation = params.memberOfGroupId
|
|
490
490
|
? sql`sa.kind IN ('standalone', 'agent') AND EXISTS (
|
|
491
491
|
SELECT 1 FROM auth.groups target
|
|
@@ -497,11 +497,18 @@ export const list = async (
|
|
|
497
497
|
WHERE sg.service_account_id = sa.id AND sg.group_id = target.id)`
|
|
498
498
|
}
|
|
499
499
|
)`
|
|
500
|
-
:
|
|
500
|
+
: params.managerOfGroupId
|
|
501
|
+
? sql`sa.kind IN ('standalone', 'agent') AND EXISTS (
|
|
502
|
+
SELECT 1 FROM auth.group_manager_service_accounts gm JOIN auth.groups g ON g.id = gm.group_id
|
|
503
|
+
WHERE gm.service_account_id = sa.id AND gm.group_id = ${params.managerOfGroupId}::uuid AND g.provider = 'local'
|
|
504
|
+
)`
|
|
505
|
+
: sql`TRUE`;
|
|
501
506
|
const machineDirect = params.memberOfGroupId
|
|
502
507
|
? sql`EXISTS (SELECT 1 FROM auth.service_account_groups sg
|
|
503
508
|
WHERE sg.service_account_id = sa.id AND sg.group_id = ${params.memberOfGroupId}::uuid)`
|
|
504
|
-
:
|
|
509
|
+
: params.managerOfGroupId
|
|
510
|
+
? sql`TRUE`
|
|
511
|
+
: sql`NULL::boolean`;
|
|
505
512
|
const where = sql`
|
|
506
513
|
${visibilityCondition}
|
|
507
514
|
AND ${kindsCondition}
|
|
@@ -264,9 +264,12 @@ export const addManager = async (params: {
|
|
|
264
264
|
provider?: UserProvider;
|
|
265
265
|
user?: string;
|
|
266
266
|
group?: string;
|
|
267
|
+
serviceAccount?: string;
|
|
267
268
|
}): Promise<MutationResult<void>> => {
|
|
268
|
-
const provider =
|
|
269
|
-
if (provider === "local")
|
|
269
|
+
const provider = (await getGroup(params.id))?.provider;
|
|
270
|
+
if (provider === "local")
|
|
271
|
+
return localGroups.addManager({ id: params.id, user: params.user, group: params.group, serviceAccount: params.serviceAccount });
|
|
272
|
+
if (params.serviceAccount) return { ok: false, error: "Machine management requires a local group", status: 400 };
|
|
270
273
|
const serviceSession = await getServiceIpaSession();
|
|
271
274
|
if (!serviceSession.ok) return serviceSession;
|
|
272
275
|
return providers.ipa.groups.addManager({
|
|
@@ -282,9 +285,12 @@ export const removeManager = async (params: {
|
|
|
282
285
|
provider?: UserProvider;
|
|
283
286
|
user?: string;
|
|
284
287
|
group?: string;
|
|
288
|
+
serviceAccount?: string;
|
|
285
289
|
}): Promise<MutationResult<void>> => {
|
|
286
|
-
const provider =
|
|
287
|
-
if (provider === "local")
|
|
290
|
+
const provider = (await getGroup(params.id))?.provider;
|
|
291
|
+
if (provider === "local")
|
|
292
|
+
return localGroups.removeManager({ id: params.id, user: params.user, group: params.group, serviceAccount: params.serviceAccount });
|
|
293
|
+
if (params.serviceAccount) return { ok: false, error: "Machine management requires a local group", status: 400 };
|
|
288
294
|
const serviceSession = await getServiceIpaSession();
|
|
289
295
|
if (!serviceSession.ok) return serviceSession;
|
|
290
296
|
return providers.ipa.groups.removeManager({
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sql } from "bun";
|
|
1
|
+
import { sql, type TransactionSQL } from "bun";
|
|
2
2
|
import type { BaseGroup, GroupMember, MutationResult, UserProvider } from "../../contracts/shared";
|
|
3
3
|
import { escapeLikePattern, isUniqueViolation } from "../postgres";
|
|
4
4
|
import { buildBaseGroup, personalOwnerJoin } from "./base-group";
|
|
@@ -23,8 +23,8 @@ const toBaseGroup = (row: LocalGroupRow): BaseGroup => ({
|
|
|
23
23
|
personalOwner: null,
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
const getLocalGroupById = async (id: string): Promise<LocalGroupRow | null> => {
|
|
27
|
-
const [row] = await
|
|
26
|
+
const getLocalGroupById = async (id: string, db: typeof sql | TransactionSQL = sql): Promise<LocalGroupRow | null> => {
|
|
27
|
+
const [row] = await db<DbRow[]>`
|
|
28
28
|
SELECT id, provider, name, description, gid_number
|
|
29
29
|
FROM auth.groups
|
|
30
30
|
WHERE id = ${id}::uuid AND provider = 'local'
|
|
@@ -39,20 +39,23 @@ const getLocalGroupById = async (id: string): Promise<LocalGroupRow | null> => {
|
|
|
39
39
|
};
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
-
const getUserProvider = async (userId: string): Promise<UserProvider | null> => {
|
|
43
|
-
const [row] = await
|
|
42
|
+
const getUserProvider = async (userId: string, db: typeof sql | TransactionSQL = sql): Promise<UserProvider | null> => {
|
|
43
|
+
const [row] = await db<DbRow[]>`SELECT provider FROM auth.users WHERE id = ${userId}`;
|
|
44
44
|
return (row?.provider as UserProvider | undefined) ?? null;
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
-
const ensureLocalGroupTreeMember = async (groupId: string): Promise<boolean> => {
|
|
48
|
-
const [row] = await
|
|
47
|
+
const ensureLocalGroupTreeMember = async (groupId: string, db: typeof sql | TransactionSQL = sql): Promise<boolean> => {
|
|
48
|
+
const [row] = await db<DbRow[]>`SELECT 1 FROM auth.groups WHERE id = ${groupId} AND provider = 'local'`;
|
|
49
49
|
return Boolean(row);
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
-
const wouldCreateLocalGroupCycle = async (
|
|
52
|
+
const wouldCreateLocalGroupCycle = async (
|
|
53
|
+
params: { parentGroupId: string; childGroupId: string },
|
|
54
|
+
db: typeof sql | TransactionSQL = sql,
|
|
55
|
+
): Promise<boolean> => {
|
|
53
56
|
if (params.parentGroupId === params.childGroupId) return true;
|
|
54
57
|
|
|
55
|
-
const [row] = await
|
|
58
|
+
const [row] = await db<DbRow[]>`
|
|
56
59
|
WITH RECURSIVE descendants AS (
|
|
57
60
|
SELECT gg.child_group_id
|
|
58
61
|
FROM auth.group_groups_v2 gg
|
|
@@ -272,7 +275,11 @@ export const getMembers = async (params: { id: string; type?: GroupMember["type"
|
|
|
272
275
|
return members;
|
|
273
276
|
};
|
|
274
277
|
|
|
275
|
-
export const getManagers = async (params: {
|
|
278
|
+
export const getManagers = async (params: {
|
|
279
|
+
id: string;
|
|
280
|
+
type?: "user" | "group" | "service_account";
|
|
281
|
+
recursive?: boolean;
|
|
282
|
+
}): Promise<GroupMember[]> => {
|
|
276
283
|
const group = await getLocalGroupById(params.id);
|
|
277
284
|
if (!group) return [];
|
|
278
285
|
|
|
@@ -342,6 +349,16 @@ export const getManagers = async (params: { id: string; type?: "user" | "group";
|
|
|
342
349
|
}
|
|
343
350
|
}
|
|
344
351
|
|
|
352
|
+
if (!params.type || params.type === "service_account") {
|
|
353
|
+
const rows = await sql<{ id: string; name: string; kind: "standalone" | "agent" }[]>`
|
|
354
|
+
SELECT sa.id, sa.name, sa.kind FROM auth.group_manager_service_accounts gm
|
|
355
|
+
JOIN auth.service_accounts sa ON sa.id = gm.service_account_id
|
|
356
|
+
WHERE gm.group_id = ${group.id}::uuid AND sa.kind IN ('standalone', 'agent')
|
|
357
|
+
ORDER BY sa.name, sa.id`;
|
|
358
|
+
managers.push(
|
|
359
|
+
...rows.map((row) => ({ type: "service_account" as const, id: row.id, displayName: row.name, serviceAccountKind: row.kind })),
|
|
360
|
+
);
|
|
361
|
+
}
|
|
345
362
|
return managers;
|
|
346
363
|
};
|
|
347
364
|
|
|
@@ -416,25 +433,28 @@ export const getManagedGroups = async (params: { id: string; recursive?: boolean
|
|
|
416
433
|
return rows.map((row) => row.group_id as string);
|
|
417
434
|
};
|
|
418
435
|
|
|
419
|
-
export const addMember = async (
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
436
|
+
export const addMember = async (
|
|
437
|
+
params: {
|
|
438
|
+
id: string;
|
|
439
|
+
user?: string;
|
|
440
|
+
group?: string;
|
|
441
|
+
serviceAccount?: string;
|
|
442
|
+
},
|
|
443
|
+
db: typeof sql | TransactionSQL = sql,
|
|
444
|
+
): Promise<MutationResult<void>> => {
|
|
445
|
+
const group = await getLocalGroupById(params.id, db);
|
|
426
446
|
if (!group) return { ok: false, error: "Group not found", status: 404 };
|
|
427
447
|
const [personal] = await sql`SELECT 1 FROM auth.personal_groups
|
|
428
448
|
WHERE group_id = ${group.id}::uuid OR group_id = ${params.group ?? null}::uuid`;
|
|
429
449
|
if (personal) return { ok: false, error: "Personal Linux groups cannot be used in group relationships", status: 409 };
|
|
430
450
|
|
|
431
451
|
if (params.serviceAccount) {
|
|
432
|
-
const [account] = await
|
|
452
|
+
const [account] = await db<{ id: string }[]>`
|
|
433
453
|
SELECT id FROM auth.service_accounts
|
|
434
454
|
WHERE id = ${params.serviceAccount}::uuid AND status = 'active' AND kind IN ('standalone', 'agent')
|
|
435
455
|
`;
|
|
436
456
|
if (!account) return { ok: false, error: "An active standalone or agent account is required", status: 400 };
|
|
437
|
-
const inserted = await
|
|
457
|
+
const inserted = await db`
|
|
438
458
|
INSERT INTO auth.service_account_groups (service_account_id, group_id)
|
|
439
459
|
VALUES (${account.id}::uuid, ${group.id}::uuid) ON CONFLICT DO NOTHING
|
|
440
460
|
`;
|
|
@@ -443,9 +463,9 @@ export const addMember = async (params: {
|
|
|
443
463
|
}
|
|
444
464
|
|
|
445
465
|
if (params.user) {
|
|
446
|
-
const provider = await getUserProvider(params.user);
|
|
466
|
+
const provider = await getUserProvider(params.user, db);
|
|
447
467
|
if (!provider) return { ok: false, error: "User not found", status: 404 };
|
|
448
|
-
const [existing] = await
|
|
468
|
+
const [existing] = await db<DbRow[]>`
|
|
449
469
|
SELECT 1
|
|
450
470
|
FROM auth.user_groups_v2
|
|
451
471
|
WHERE user_id = ${params.user}::uuid
|
|
@@ -453,14 +473,14 @@ export const addMember = async (params: {
|
|
|
453
473
|
LIMIT 1
|
|
454
474
|
`;
|
|
455
475
|
if (existing) return { ok: false, error: "User is already a direct member of this group", status: 409 };
|
|
456
|
-
await
|
|
476
|
+
await db`INSERT INTO auth.user_groups_v2 (user_id, group_id) VALUES (${params.user}, ${group.id}) ON CONFLICT DO NOTHING`;
|
|
457
477
|
return { ok: true, data: undefined };
|
|
458
478
|
}
|
|
459
479
|
|
|
460
480
|
if (params.group) {
|
|
461
|
-
const isLocal = await ensureLocalGroupTreeMember(params.group);
|
|
481
|
+
const isLocal = await ensureLocalGroupTreeMember(params.group, db);
|
|
462
482
|
if (!isLocal) return { ok: false, error: "Only local groups can be nested into local groups", status: 400 };
|
|
463
|
-
const [existing] = await
|
|
483
|
+
const [existing] = await db<DbRow[]>`
|
|
464
484
|
SELECT 1
|
|
465
485
|
FROM auth.group_groups_v2
|
|
466
486
|
WHERE parent_group_id = ${group.id}::uuid
|
|
@@ -468,51 +488,69 @@ export const addMember = async (params: {
|
|
|
468
488
|
LIMIT 1
|
|
469
489
|
`;
|
|
470
490
|
if (existing) return { ok: false, error: "Group is already a direct member of this group", status: 409 };
|
|
471
|
-
if (await wouldCreateLocalGroupCycle({ parentGroupId: group.id, childGroupId: params.group })) {
|
|
491
|
+
if (await wouldCreateLocalGroupCycle({ parentGroupId: group.id, childGroupId: params.group }, db)) {
|
|
472
492
|
return { ok: false, error: "Local group nesting cannot create cycles", status: 400 };
|
|
473
493
|
}
|
|
474
|
-
await
|
|
494
|
+
await db`INSERT INTO auth.group_groups_v2 (parent_group_id, child_group_id) VALUES (${group.id}, ${params.group}) ON CONFLICT DO NOTHING`;
|
|
475
495
|
return { ok: true, data: undefined };
|
|
476
496
|
}
|
|
477
497
|
|
|
478
498
|
return { ok: false, error: "Missing member", status: 400 };
|
|
479
499
|
};
|
|
480
500
|
|
|
481
|
-
export const removeMember = async (
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
501
|
+
export const removeMember = async (
|
|
502
|
+
params: {
|
|
503
|
+
id: string;
|
|
504
|
+
user?: string;
|
|
505
|
+
group?: string;
|
|
506
|
+
serviceAccount?: string;
|
|
507
|
+
},
|
|
508
|
+
db: typeof sql | TransactionSQL = sql,
|
|
509
|
+
): Promise<MutationResult<void>> => {
|
|
510
|
+
const group = await getLocalGroupById(params.id, db);
|
|
488
511
|
if (!group) return { ok: false, error: "Group not found", status: 404 };
|
|
489
512
|
|
|
490
513
|
if (params.serviceAccount) {
|
|
491
|
-
await
|
|
514
|
+
await db`DELETE FROM auth.service_account_groups
|
|
492
515
|
WHERE service_account_id = ${params.serviceAccount}::uuid AND group_id = ${group.id}::uuid`;
|
|
493
516
|
return { ok: true, data: undefined };
|
|
494
517
|
}
|
|
495
518
|
|
|
496
519
|
if (params.user) {
|
|
497
|
-
await
|
|
520
|
+
await db`DELETE FROM auth.user_groups_v2 WHERE user_id = ${params.user}::uuid AND group_id = ${group.id}::uuid`;
|
|
498
521
|
return { ok: true, data: undefined };
|
|
499
522
|
}
|
|
500
523
|
|
|
501
524
|
if (params.group) {
|
|
502
|
-
await
|
|
525
|
+
await db`DELETE FROM auth.group_groups_v2 WHERE parent_group_id = ${group.id}::uuid AND child_group_id = ${params.group}::uuid`;
|
|
503
526
|
return { ok: true, data: undefined };
|
|
504
527
|
}
|
|
505
528
|
|
|
506
529
|
return { ok: false, error: "Missing member", status: 400 };
|
|
507
530
|
};
|
|
508
531
|
|
|
509
|
-
export const addManager = async (params: {
|
|
532
|
+
export const addManager = async (params: {
|
|
533
|
+
id: string;
|
|
534
|
+
user?: string;
|
|
535
|
+
group?: string;
|
|
536
|
+
serviceAccount?: string;
|
|
537
|
+
}): Promise<MutationResult<void>> => {
|
|
510
538
|
const group = await getLocalGroupById(params.id);
|
|
511
539
|
if (!group) return { ok: false, error: "Group not found", status: 404 };
|
|
512
540
|
const [personal] = await sql`SELECT 1 FROM auth.personal_groups
|
|
513
541
|
WHERE group_id = ${group.id}::uuid OR group_id = ${params.group ?? null}::uuid`;
|
|
514
542
|
if (personal) return { ok: false, error: "Personal Linux groups cannot be used in group relationships", status: 409 };
|
|
515
543
|
|
|
544
|
+
if (params.serviceAccount) {
|
|
545
|
+
const [account] = await sql`SELECT id FROM auth.service_accounts
|
|
546
|
+
WHERE id = ${params.serviceAccount}::uuid AND status = 'active' AND kind IN ('standalone', 'agent')`;
|
|
547
|
+
if (!account) return { ok: false, error: "An active standalone or agent account is required", status: 400 };
|
|
548
|
+
const inserted = await sql`INSERT INTO auth.group_manager_service_accounts(group_id, service_account_id)
|
|
549
|
+
VALUES (${group.id}::uuid, ${params.serviceAccount}::uuid) ON CONFLICT DO NOTHING`;
|
|
550
|
+
if (inserted.count === 0) return { ok: false, error: "Service account already manages this group", status: 409 };
|
|
551
|
+
return { ok: true, data: undefined };
|
|
552
|
+
}
|
|
553
|
+
|
|
516
554
|
if (params.user) {
|
|
517
555
|
const provider = await getUserProvider(params.user);
|
|
518
556
|
if (!provider) return { ok: false, error: "User not found", status: 404 };
|
|
@@ -546,10 +584,21 @@ export const addManager = async (params: { id: string; user?: string; group?: st
|
|
|
546
584
|
return { ok: false, error: "Missing manager", status: 400 };
|
|
547
585
|
};
|
|
548
586
|
|
|
549
|
-
export const removeManager = async (params: {
|
|
587
|
+
export const removeManager = async (params: {
|
|
588
|
+
id: string;
|
|
589
|
+
user?: string;
|
|
590
|
+
group?: string;
|
|
591
|
+
serviceAccount?: string;
|
|
592
|
+
}): Promise<MutationResult<void>> => {
|
|
550
593
|
const group = await getLocalGroupById(params.id);
|
|
551
594
|
if (!group) return { ok: false, error: "Group not found", status: 404 };
|
|
552
595
|
|
|
596
|
+
if (params.serviceAccount) {
|
|
597
|
+
await sql`DELETE FROM auth.group_manager_service_accounts
|
|
598
|
+
WHERE group_id = ${group.id}::uuid AND service_account_id = ${params.serviceAccount}::uuid`;
|
|
599
|
+
return { ok: true, data: undefined };
|
|
600
|
+
}
|
|
601
|
+
|
|
553
602
|
if (params.user) {
|
|
554
603
|
await sql`DELETE FROM auth.group_manager_users_v2 WHERE group_id = ${group.id}::uuid AND user_id = ${params.user}::uuid`;
|
|
555
604
|
return { ok: true, data: undefined };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import type { ServiceAccountRequestActor } from "../../server/middleware/auth";
|
|
3
|
+
import { err, fail, ok, type Result } from "../../server/services";
|
|
4
|
+
import { audit } from "../audit";
|
|
5
|
+
import * as localGroups from "./local-groups";
|
|
6
|
+
|
|
7
|
+
/** Runs the existing local membership operation while its direct management grant is locked. */
|
|
8
|
+
export async function mutateMachineGroupMember(params: {
|
|
9
|
+
actor: ServiceAccountRequestActor;
|
|
10
|
+
id: string;
|
|
11
|
+
userId?: string;
|
|
12
|
+
groupId?: string;
|
|
13
|
+
serviceAccountId?: string;
|
|
14
|
+
operation: "add" | "remove";
|
|
15
|
+
requestId?: string;
|
|
16
|
+
}): Promise<Result<void>> {
|
|
17
|
+
const { actor } = params;
|
|
18
|
+
const action = `accounts.group.member.${params.operation}`;
|
|
19
|
+
const target = { type: "group", id: params.id };
|
|
20
|
+
const metadata = {
|
|
21
|
+
actorServiceAccountId: actor.serviceAccount.id,
|
|
22
|
+
credentialId: actor.credentialId ?? null,
|
|
23
|
+
oauthClientId: actor.oauthClientId ?? null,
|
|
24
|
+
userId: params.userId ?? null,
|
|
25
|
+
groupId: params.groupId ?? null,
|
|
26
|
+
serviceAccountId: params.serviceAccountId ?? null,
|
|
27
|
+
};
|
|
28
|
+
const unexpired = (expiration: string | Date | null | undefined) =>
|
|
29
|
+
expiration == null || (typeof expiration === "string" ? Date.parse(expiration) : expiration.getTime()) > Date.now();
|
|
30
|
+
const denied = () => fail(err.forbidden("A current management grant for this group and write scope are required"));
|
|
31
|
+
if (
|
|
32
|
+
actor.delegatedUser ||
|
|
33
|
+
!["standalone", "agent"].includes(actor.serviceAccount.kind) ||
|
|
34
|
+
!actor.scopes.some((scope) => scope === "write" || scope === "admin") ||
|
|
35
|
+
!unexpired(actor.credentialExpiresAt) ||
|
|
36
|
+
(!actor.credentialId && (!actor.oauthClientId || !actor.credentialExpiresAt))
|
|
37
|
+
)
|
|
38
|
+
return audit.recordResult({ action, target, metadata, requestId: params.requestId, result: denied() });
|
|
39
|
+
|
|
40
|
+
return sql.begin(async (db) => {
|
|
41
|
+
// Account -> credential -> group -> grant matches the group deletion cascade.
|
|
42
|
+
const [account] = await db`SELECT id FROM auth.service_accounts
|
|
43
|
+
WHERE id = ${actor.serviceAccount.id}::uuid AND status = 'active' AND kind IN ('standalone','agent') FOR SHARE`;
|
|
44
|
+
let allowed = Boolean(account);
|
|
45
|
+
let storedExpiration: Date | null = null;
|
|
46
|
+
if (allowed && actor.credentialId) {
|
|
47
|
+
const [credential] = await db<{ expires_at: Date | null }[]>`SELECT expires_at FROM auth.service_account_credentials
|
|
48
|
+
WHERE id = ${actor.credentialId}::uuid AND service_account_id = ${actor.serviceAccount.id}::uuid
|
|
49
|
+
AND status = 'active' AND (expires_at IS NULL OR expires_at > clock_timestamp())
|
|
50
|
+
AND scopes && ARRAY['write','admin']::text[] FOR SHARE`;
|
|
51
|
+
allowed = Boolean(credential);
|
|
52
|
+
storedExpiration = credential?.expires_at ?? null;
|
|
53
|
+
} else if (allowed) {
|
|
54
|
+
const [client] = await db`SELECT id FROM oauth.clients WHERE client_id=${actor.oauthClientId ?? null} FOR SHARE`;
|
|
55
|
+
allowed = Boolean(client);
|
|
56
|
+
}
|
|
57
|
+
const [group] = allowed ? await db`SELECT id FROM auth.groups WHERE id=${params.id}::uuid AND provider='local' FOR SHARE` : [];
|
|
58
|
+
const [grant] = group
|
|
59
|
+
? await db`SELECT group_id FROM auth.group_manager_service_accounts
|
|
60
|
+
WHERE group_id=${params.id}::uuid AND service_account_id=${actor.serviceAccount.id}::uuid FOR SHARE`
|
|
61
|
+
: [];
|
|
62
|
+
let result: Result<void> = denied();
|
|
63
|
+
// The authorization locks define admission; time must be current after any lock wait.
|
|
64
|
+
if (grant && unexpired(actor.credentialExpiresAt) && unexpired(storedExpiration)) {
|
|
65
|
+
if ([params.userId, params.groupId, params.serviceAccountId].filter(Boolean).length !== 1) {
|
|
66
|
+
result = fail(err.badInput("Choose exactly one member"));
|
|
67
|
+
} else {
|
|
68
|
+
const changed = await localGroups[params.operation === "add" ? "addMember" : "removeMember"](
|
|
69
|
+
{
|
|
70
|
+
id: params.id,
|
|
71
|
+
user: params.userId,
|
|
72
|
+
group: params.groupId,
|
|
73
|
+
serviceAccount: params.serviceAccountId,
|
|
74
|
+
},
|
|
75
|
+
db,
|
|
76
|
+
);
|
|
77
|
+
result = changed.ok
|
|
78
|
+
? ok()
|
|
79
|
+
: fail({ code: changed.status === 403 ? "FORBIDDEN" : "GROUP_MUTATION_FAILED", message: changed.error, status: changed.status });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
await audit.record(
|
|
83
|
+
{
|
|
84
|
+
action,
|
|
85
|
+
target,
|
|
86
|
+
metadata,
|
|
87
|
+
requestId: params.requestId,
|
|
88
|
+
outcome: result.ok ? "allowed" : result.error.status === 403 ? "denied" : "failed",
|
|
89
|
+
},
|
|
90
|
+
db,
|
|
91
|
+
);
|
|
92
|
+
return result;
|
|
93
|
+
});
|
|
94
|
+
}
|