@k2b/cloud 0.46.0 → 0.48.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 +8 -4
- package/scripts/build.ts +5 -1
- package/scripts/preload.ts +5 -1
- package/src/_internal/app-access-manager-sql.ts +10 -0
- package/src/_internal/bounded-json.ts +7 -1
- package/src/_internal/capabilities.ts +8 -0
- package/src/_internal/define-app.ts +69 -2
- package/src/_internal/heartbeat.ts +21 -0
- package/src/_internal/nats-connection.ts +10 -9
- package/src/_internal/postgres-application-name.ts +11 -0
- package/src/_internal/process-sync.ts +15 -6
- package/src/_internal/public-surface.ts +76 -0
- package/src/_internal/purpose-name.ts +7 -0
- package/src/_internal/readiness.ts +6 -1
- package/src/_internal/registry-validation.ts +1 -0
- package/src/_internal/registry.ts +49 -3
- package/src/_internal/resource-principals.ts +4 -34
- package/src/_internal/workload-credential.ts +154 -0
- package/src/_internal/workload-protocol.ts +36 -0
- package/src/ai/application-tasks.ts +5 -2
- package/src/ai/chart-block.ts +36 -711
- package/src/ai/default-tool-contracts.ts +21 -259
- package/src/api/admin-app-domains.ts +62 -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/app-bootstrap.ts +131 -0
- package/src/api/capabilities.ts +17 -4
- package/src/api/index.ts +8 -0
- package/src/api/settings-ops.ts +35 -0
- package/src/app-key.ts +125 -0
- package/src/browser/CloudResourceSearch.tsx +48 -41
- package/src/capabilities/discussion-providers.ts +3 -2
- package/src/capabilities/resource-readers.ts +13 -2
- package/src/capabilities/response.ts +6 -2
- package/src/capabilities/server-invoke.ts +73 -29
- package/src/capabilities/server.ts +1 -1
- package/src/capabilities/types.ts +1 -1
- package/src/cli/admin/app-credentials.ts +32 -7
- package/src/cli/admin/app-domains.ts +140 -0
- package/src/cli/admin/app-keys.ts +226 -0
- package/src/cli/admin/index.ts +109 -84
- package/src/cli/admin/settings.ts +62 -0
- package/src/config/define-env.ts +63 -8
- package/src/config/env.ts +34 -22
- package/src/config/index.ts +12 -1
- package/src/config/workflow-ai.ts +28 -0
- package/src/contracts/app-domains.ts +91 -0
- package/src/contracts/app-status.ts +104 -0
- package/src/contracts/capabilities.ts +22 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/settings-env.ts +21 -0
- package/src/contracts/settings-types.ts +6 -0
- package/src/index.ts +10 -0
- package/src/runtime/bootstrap-client.ts +195 -0
- package/src/runtime/bootstrap-protocol.ts +60 -0
- package/src/runtime/clients.ts +72 -0
- package/src/runtime/connections.ts +46 -0
- package/src/runtime/resolve-connections.ts +91 -0
- package/src/server/app-key.ts +14 -0
- package/src/server/client-ip.ts +1 -1
- package/src/server/index.ts +1 -0
- package/src/services/app-access-management.ts +1 -8
- package/src/services/app-admin-facts.ts +160 -0
- package/src/services/app-bootstrap.ts +134 -0
- package/src/services/app-domain-check.ts +152 -0
- package/src/services/app-domains.ts +172 -0
- package/src/services/app-keys.ts +301 -0
- package/src/services/downstream-budget.ts +2 -2
- package/src/services/gateway.ts +6 -0
- package/src/services/identity/index.ts +3 -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 +8 -1
- package/src/services/links/index.ts +16 -0
- package/src/services/links/runtime.ts +44 -0
- package/src/services/links/store.ts +105 -0
- package/src/services/logging/index.ts +7 -5
- package/src/services/logging/redaction.ts +6 -1
- package/src/services/logging/trace.ts +8 -8
- package/src/services/outgoing-mail/resolve-host.ts +5 -4
- package/src/services/request-cache-redis.ts +2 -2
- 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 +119 -1
- package/src/services/settings/crypto.ts +2 -2
- 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 +78 -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/admin-navigation.ts +0 -1
- package/src/ssr/platform-messages.ts +0 -2
- package/src/start.ts +57 -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,172 @@
|
|
|
1
|
+
import { err, fail, ok, type Result } from "@k2b/stdlib";
|
|
2
|
+
import type { SQL } from "bun";
|
|
3
|
+
import { getApp } from "../_internal/registry";
|
|
4
|
+
import { declaresPublicSurface, normalizeAppDomainHost } from "../contracts/app-domains";
|
|
5
|
+
import type { User } from "../contracts/shared";
|
|
6
|
+
import { sql } from "../runtime/clients";
|
|
7
|
+
import { publicCloudOrigin } from "../shared/app-url";
|
|
8
|
+
import { type AppDomainStatus, checkAppDomain } from "./app-domain-check";
|
|
9
|
+
import { audit } from "./audit";
|
|
10
|
+
import { get } from "./settings";
|
|
11
|
+
|
|
12
|
+
export type AdminAppDomain = {
|
|
13
|
+
host: string;
|
|
14
|
+
redirectTo: string | null;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
active: boolean;
|
|
17
|
+
status: AppDomainStatus | null;
|
|
18
|
+
};
|
|
19
|
+
type AdminActor = Pick<User, "id" | "uid" | "provider" | "roles">;
|
|
20
|
+
type AppInput = { actor: AdminActor; appId: string };
|
|
21
|
+
type DomainInput = AppInput & { host: string };
|
|
22
|
+
type Row = { host: string; app_id: string; redirect_to: string | null; created_at: Date; status: AppDomainStatus | null };
|
|
23
|
+
type Dependencies = { db?: SQL; getApp?: typeof getApp; cloudUrl?: () => string | Promise<string>; probe?: typeof checkAppDomain };
|
|
24
|
+
const normalizeHost = (value: string) => (value.includes("\\") ? null : normalizeAppDomainHost(value));
|
|
25
|
+
const invalid = (code: string, message: string) => fail({ ...err.badInput(message), code });
|
|
26
|
+
const conflict = (code: string, message: string) => fail({ ...err.conflict("App domain"), code, message });
|
|
27
|
+
const notFound = () => fail({ ...err.notFound("App domain"), code: "APP_DOMAIN_NOT_FOUND" });
|
|
28
|
+
const authorized = (input: AppInput): Result<void> => {
|
|
29
|
+
if (!input.actor.roles.includes("admin")) return fail(err.forbidden("Administrator access required"));
|
|
30
|
+
if (!/^[a-z][a-z0-9-]{0,79}$/.test(input.appId)) return invalid("APP_DOMAIN_INVALID_APP", "Invalid app ID");
|
|
31
|
+
return ok();
|
|
32
|
+
};
|
|
33
|
+
const domain = (row: Row, active: boolean): AdminAppDomain => ({
|
|
34
|
+
host: row.host,
|
|
35
|
+
redirectTo: row.redirect_to,
|
|
36
|
+
createdAt: row.created_at.toISOString(),
|
|
37
|
+
active,
|
|
38
|
+
status: row.status,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** All domain mutations share one transaction lock; the host primary key also enforces unique ownership. */
|
|
42
|
+
export const createAppDomainService = (dependencies: Dependencies = {}) => {
|
|
43
|
+
const app = dependencies.getApp ?? getApp;
|
|
44
|
+
const database = () => dependencies.db ?? sql;
|
|
45
|
+
const active = async (appId: string) => {
|
|
46
|
+
const entry = await app(appId);
|
|
47
|
+
return entry !== null && declaresPublicSurface(entry);
|
|
48
|
+
};
|
|
49
|
+
const record = (db: SQL, input: DomainInput, action: string, redirectTo?: string | null) =>
|
|
50
|
+
audit.record(
|
|
51
|
+
{
|
|
52
|
+
action: `app_domain.${action}`,
|
|
53
|
+
outcome: "allowed",
|
|
54
|
+
actor: { userId: input.actor.id, uid: input.actor.uid, provider: input.actor.provider, roles: input.actor.roles },
|
|
55
|
+
target: { type: "app_domain", id: input.host, label: input.host },
|
|
56
|
+
metadata: { appId: input.appId, ...(redirectTo !== undefined ? { redirectTo } : {}) },
|
|
57
|
+
},
|
|
58
|
+
db,
|
|
59
|
+
);
|
|
60
|
+
const references = (db: SQL, appId: string, host: string) => db<{ host: string }[]>`
|
|
61
|
+
SELECT host FROM settings.app_domains WHERE app_id = ${appId} AND redirect_to = ${host} ORDER BY host`;
|
|
62
|
+
const validateRedirect = async (db: SQL, appId: string, host: string, target: string): Promise<Result<void>> => {
|
|
63
|
+
if (host === target) return invalid("APP_DOMAIN_REDIRECT_TARGET", "A domain cannot redirect to itself");
|
|
64
|
+
const refs = await references(db, appId, host);
|
|
65
|
+
if (refs.length) return conflict("APP_DOMAIN_REFERENCED", `Domains redirect to ${host}: ${refs.map((row) => row.host).join(", ")}`);
|
|
66
|
+
const [row] = await db<Row[]>`SELECT * FROM settings.app_domains WHERE host = ${target} AND app_id = ${appId}`;
|
|
67
|
+
if (!row || row.redirect_to)
|
|
68
|
+
return invalid("APP_DOMAIN_REDIRECT_TARGET", "Redirect target must be a domain of this app without a redirect");
|
|
69
|
+
return ok();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
async list(input: AppInput): Promise<Result<{ domains: AdminAppDomain[] }>> {
|
|
74
|
+
const permission = authorized(input);
|
|
75
|
+
if (!permission.ok) return permission;
|
|
76
|
+
const [rows, isActive] = await Promise.all([
|
|
77
|
+
database()<Row[]>`SELECT * FROM settings.app_domains WHERE app_id = ${input.appId} ORDER BY host`,
|
|
78
|
+
active(input.appId),
|
|
79
|
+
]);
|
|
80
|
+
return ok({ domains: rows.map((row) => domain(row, isActive)) });
|
|
81
|
+
},
|
|
82
|
+
async add(input: DomainInput & { redirectTo?: string }): Promise<Result<AdminAppDomain>> {
|
|
83
|
+
const permission = authorized(input);
|
|
84
|
+
if (!permission.ok) return permission;
|
|
85
|
+
const host = normalizeHost(input.host);
|
|
86
|
+
if (!host) return invalid("APP_DOMAIN_INVALID_HOST", "Expected a DNS host name without a port, path or wildcard");
|
|
87
|
+
const cloudHost = normalizeAppDomainHost(
|
|
88
|
+
new URL(publicCloudOrigin(await (dependencies.cloudUrl ?? (() => get<string>("app.url")))())).hostname,
|
|
89
|
+
);
|
|
90
|
+
if (host === cloudHost) return invalid("APP_DOMAIN_CLOUD_HOST", "The Cloud host cannot be assigned to an app");
|
|
91
|
+
const isActive = await active(input.appId);
|
|
92
|
+
const redirectTo = input.redirectTo === undefined ? null : normalizeHost(input.redirectTo);
|
|
93
|
+
if (input.redirectTo !== undefined && !redirectTo) return invalid("APP_DOMAIN_INVALID_HOST", "Invalid redirect host");
|
|
94
|
+
return database().begin(async (db) => {
|
|
95
|
+
await db`SELECT pg_advisory_xact_lock(hashtextextended('core.app-domains', 0))`;
|
|
96
|
+
const [known] = await db<{ app_id: string }[]>`SELECT app_id FROM platform.application_packages WHERE app_id = ${input.appId}`;
|
|
97
|
+
if (!known) return invalid("APP_DOMAIN_UNKNOWN_APP", "Unknown app ID; the app must have registered its package at least once");
|
|
98
|
+
const existing = await db`SELECT host FROM settings.app_domains WHERE host = ${host}`;
|
|
99
|
+
if (existing.length) return conflict("APP_DOMAIN_ASSIGNED", `Domain ${host} is already assigned`);
|
|
100
|
+
if (redirectTo) {
|
|
101
|
+
const valid = await validateRedirect(db, input.appId, host, redirectTo);
|
|
102
|
+
if (!valid.ok) return valid;
|
|
103
|
+
}
|
|
104
|
+
const [row] = await db<Row[]>`INSERT INTO settings.app_domains(host, app_id, redirect_to)
|
|
105
|
+
VALUES (${host}, ${input.appId}, ${redirectTo}) ON CONFLICT (host) DO NOTHING RETURNING *`;
|
|
106
|
+
if (!row) return conflict("APP_DOMAIN_ASSIGNED", `Domain ${host} is already assigned`);
|
|
107
|
+
await record(db, { ...input, host }, "add", redirectTo);
|
|
108
|
+
return ok(domain(row, isActive));
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
async set(input: DomainInput & { redirectTo: string | null }): Promise<Result<AdminAppDomain>> {
|
|
112
|
+
const permission = authorized(input);
|
|
113
|
+
if (!permission.ok) return permission;
|
|
114
|
+
const host = normalizeHost(input.host);
|
|
115
|
+
if (!host) return invalid("APP_DOMAIN_INVALID_HOST", "Invalid domain host");
|
|
116
|
+
const redirectTo = input.redirectTo === null ? null : normalizeHost(input.redirectTo);
|
|
117
|
+
if (input.redirectTo !== null && !redirectTo) return invalid("APP_DOMAIN_INVALID_HOST", "Invalid redirect host");
|
|
118
|
+
const isActive = await active(input.appId);
|
|
119
|
+
return database().begin(async (db) => {
|
|
120
|
+
await db`SELECT pg_advisory_xact_lock(hashtextextended('core.app-domains', 0))`;
|
|
121
|
+
const [existing] = await db<Row[]>`SELECT * FROM settings.app_domains WHERE host = ${host} AND app_id = ${input.appId}`;
|
|
122
|
+
if (!existing) return notFound();
|
|
123
|
+
if (redirectTo) {
|
|
124
|
+
const valid = await validateRedirect(db, input.appId, host, redirectTo);
|
|
125
|
+
if (!valid.ok) return valid;
|
|
126
|
+
}
|
|
127
|
+
const [row] = await db<
|
|
128
|
+
Row[]
|
|
129
|
+
>`UPDATE settings.app_domains SET redirect_to = ${redirectTo} WHERE host = ${host} AND app_id = ${input.appId} RETURNING *`;
|
|
130
|
+
if (!row) return notFound();
|
|
131
|
+
await record(db, { ...input, host }, "set", redirectTo);
|
|
132
|
+
return ok(domain(row, isActive));
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
async remove(input: DomainInput): Promise<Result<{ deleted: true }>> {
|
|
136
|
+
const permission = authorized(input);
|
|
137
|
+
if (!permission.ok) return permission;
|
|
138
|
+
const host = normalizeHost(input.host);
|
|
139
|
+
if (!host) return invalid("APP_DOMAIN_INVALID_HOST", "Invalid domain host");
|
|
140
|
+
return database().begin(async (db) => {
|
|
141
|
+
await db`SELECT pg_advisory_xact_lock(hashtextextended('core.app-domains', 0))`;
|
|
142
|
+
const refs = await references(db, input.appId, host);
|
|
143
|
+
if (refs.length) return conflict("APP_DOMAIN_REFERENCED", `Domains redirect to ${host}: ${refs.map((row) => row.host).join(", ")}`);
|
|
144
|
+
const rows = await db`DELETE FROM settings.app_domains WHERE host = ${host} AND app_id = ${input.appId} RETURNING host`;
|
|
145
|
+
if (!rows.length) return notFound();
|
|
146
|
+
await record(db, { ...input, host }, "remove");
|
|
147
|
+
return ok({ deleted: true as const });
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
async check(input: DomainInput): Promise<Result<{ status: AppDomainStatus | null }>> {
|
|
151
|
+
const permission = authorized(input);
|
|
152
|
+
if (!permission.ok) return permission;
|
|
153
|
+
const host = normalizeHost(input.host);
|
|
154
|
+
if (!host) return invalid("APP_DOMAIN_INVALID_HOST", "Invalid domain host");
|
|
155
|
+
const db = database();
|
|
156
|
+
const [claimed] = await db<{ check_started_at: string }[]>`UPDATE settings.app_domains SET check_started_at = now()
|
|
157
|
+
WHERE host = ${host} AND app_id = ${input.appId} AND (check_started_at IS NULL OR check_started_at <= now() - interval '60 seconds')
|
|
158
|
+
RETURNING check_started_at::text`;
|
|
159
|
+
if (!claimed) {
|
|
160
|
+
const [row] = await db<Row[]>`SELECT * FROM settings.app_domains WHERE host = ${host} AND app_id = ${input.appId}`;
|
|
161
|
+
return row ? ok({ status: row.status }) : notFound();
|
|
162
|
+
}
|
|
163
|
+
const status = await (dependencies.probe ?? checkAppDomain)(host);
|
|
164
|
+
// A removed/reassigned domain must never inherit an earlier owner's in-flight result.
|
|
165
|
+
await db`UPDATE settings.app_domains SET status = (${JSON.stringify(status)}::text)::jsonb
|
|
166
|
+
WHERE host = ${host} AND app_id = ${input.appId} AND check_started_at = ${claimed.check_started_at}::timestamptz`;
|
|
167
|
+
return ok({ status });
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const appDomains = createAppDomainService();
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { err, fail, ok, type Result } from "@k2b/stdlib";
|
|
2
|
+
import { type SQL, sql } from "bun";
|
|
3
|
+
import { appKeyFingerprint, validateAppKeyAppId } from "../app-key";
|
|
4
|
+
import type { User } from "../contracts/shared";
|
|
5
|
+
import { audit } from "./audit";
|
|
6
|
+
import { serviceAccounts } from "./service-accounts";
|
|
7
|
+
|
|
8
|
+
type AdminActor = Pick<User, "id" | "uid" | "provider" | "roles">;
|
|
9
|
+
export type AppKeyMetadata = {
|
|
10
|
+
keyId: string;
|
|
11
|
+
appId: string;
|
|
12
|
+
serviceAccountId: string;
|
|
13
|
+
kind: "app_key";
|
|
14
|
+
name: string;
|
|
15
|
+
publicKey: string;
|
|
16
|
+
fingerprint: string;
|
|
17
|
+
status: "active" | "expired" | "revoked";
|
|
18
|
+
accountStatus: "active" | "disabled";
|
|
19
|
+
createdAt: string;
|
|
20
|
+
createdBy: string | null;
|
|
21
|
+
expiresAt: string | null;
|
|
22
|
+
revokedAt: string | null;
|
|
23
|
+
revokedBy: string | null;
|
|
24
|
+
lastUsedAt: string | null;
|
|
25
|
+
};
|
|
26
|
+
type Row = {
|
|
27
|
+
id: string;
|
|
28
|
+
app_id: string;
|
|
29
|
+
service_account_id: string;
|
|
30
|
+
name: string;
|
|
31
|
+
public_key: string;
|
|
32
|
+
fingerprint: string;
|
|
33
|
+
key_status: AppKeyMetadata["status"];
|
|
34
|
+
account_status: AppKeyMetadata["accountStatus"];
|
|
35
|
+
created_at: Date;
|
|
36
|
+
created_by: string | null;
|
|
37
|
+
expires_at: Date | null;
|
|
38
|
+
revoked_at: Date | null;
|
|
39
|
+
revoked_by: string | null;
|
|
40
|
+
last_used_at: Date | null;
|
|
41
|
+
};
|
|
42
|
+
const map = (row: Row): AppKeyMetadata => ({
|
|
43
|
+
keyId: row.id,
|
|
44
|
+
appId: row.app_id,
|
|
45
|
+
serviceAccountId: row.service_account_id,
|
|
46
|
+
kind: "app_key",
|
|
47
|
+
name: row.name,
|
|
48
|
+
publicKey: row.public_key,
|
|
49
|
+
fingerprint: row.fingerprint,
|
|
50
|
+
status: row.key_status,
|
|
51
|
+
accountStatus: row.account_status,
|
|
52
|
+
createdAt: row.created_at.toISOString(),
|
|
53
|
+
createdBy: row.created_by,
|
|
54
|
+
expiresAt: row.expires_at?.toISOString() ?? null,
|
|
55
|
+
revokedAt: row.revoked_at?.toISOString() ?? null,
|
|
56
|
+
revokedBy: row.revoked_by,
|
|
57
|
+
lastUsedAt: row.last_used_at?.toISOString() ?? null,
|
|
58
|
+
});
|
|
59
|
+
const columns = `c.*, sa.app_id, sa.status AS account_status,
|
|
60
|
+
CASE WHEN c.status = 'revoked' THEN 'revoked' WHEN c.expires_at <= now() THEN 'expired' ELSE 'active' END AS key_status`;
|
|
61
|
+
const actorForAudit = (actor: AdminActor) => ({ userId: actor.id, uid: actor.uid, provider: actor.provider, roles: actor.roles });
|
|
62
|
+
const validateAdmin = (actor: AdminActor, appId?: string): Result<void> => {
|
|
63
|
+
if (!actor.roles.includes("admin")) return fail(err.forbidden("Administrator access required"));
|
|
64
|
+
if (appId !== undefined) {
|
|
65
|
+
try {
|
|
66
|
+
validateAppKeyAppId(appId);
|
|
67
|
+
} catch {
|
|
68
|
+
return fail(err.badInput("Invalid or reserved app ID"));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return ok();
|
|
72
|
+
};
|
|
73
|
+
const pageParams = (params: { page?: number; perPage?: number }) => ({
|
|
74
|
+
page: Math.max(1, Math.trunc(params.page ?? 1)),
|
|
75
|
+
perPage: Math.min(500, Math.max(1, Math.trunc(params.perPage ?? 100))),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/** Durable DB identity, initialized transactionally by Core's migration; never cached or read from env. */
|
|
79
|
+
export const getInstallationId = async (db: SQL = sql): Promise<string> => {
|
|
80
|
+
const [row] = await db<{ id: string }[]>`SELECT id FROM auth.installation WHERE singleton = true`;
|
|
81
|
+
if (!row) throw new Error("Core installation identity is not initialized; run Core migrations");
|
|
82
|
+
return row.id;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const registerAppKey = async (params: {
|
|
86
|
+
actor: AdminActor;
|
|
87
|
+
appId: string;
|
|
88
|
+
publicKey: string;
|
|
89
|
+
name?: string;
|
|
90
|
+
expiresAt?: string;
|
|
91
|
+
}): Promise<
|
|
92
|
+
Result<{
|
|
93
|
+
keyId: string;
|
|
94
|
+
installationId: string;
|
|
95
|
+
fingerprint: string;
|
|
96
|
+
status: AppKeyMetadata["status"];
|
|
97
|
+
accountStatus: AppKeyMetadata["accountStatus"];
|
|
98
|
+
}>
|
|
99
|
+
> => {
|
|
100
|
+
const valid = validateAdmin(params.actor, params.appId);
|
|
101
|
+
if (!valid.ok) return valid;
|
|
102
|
+
const name = params.name?.trim() ?? "App key";
|
|
103
|
+
if (!name || name.length > 120) return fail(err.badInput("Key name must contain 1 to 120 characters"));
|
|
104
|
+
let fingerprint: string;
|
|
105
|
+
try {
|
|
106
|
+
fingerprint = await appKeyFingerprint(params.publicKey);
|
|
107
|
+
} catch {
|
|
108
|
+
return fail(err.badInput("Invalid Ed25519 public key: expected a canonical 32-byte base64url point"));
|
|
109
|
+
}
|
|
110
|
+
const expiresAt = params.expiresAt ? new Date(params.expiresAt) : null;
|
|
111
|
+
if (expiresAt && !Number.isFinite(expiresAt.getTime())) return fail(err.badInput("Expiry must be in the future"));
|
|
112
|
+
const account = await serviceAccounts.getOrCreateResourceBound({
|
|
113
|
+
name: `${params.appId} workload`,
|
|
114
|
+
appId: params.appId,
|
|
115
|
+
resourceType: "cloud.app",
|
|
116
|
+
resourceId: params.appId,
|
|
117
|
+
createdBy: params.actor.id,
|
|
118
|
+
});
|
|
119
|
+
if (!account.ok) return account;
|
|
120
|
+
return sql.begin(async (db) => {
|
|
121
|
+
// Resource-bound accounts carry no unique index, so concurrent first enrollments can
|
|
122
|
+
// create more than one account for an app. One transaction lock per app serializes
|
|
123
|
+
// enrollment across Core replicas; keys count and land on the oldest account, the
|
|
124
|
+
// same one getByResource returns.
|
|
125
|
+
await db`SELECT pg_advisory_xact_lock(hashtextextended(${`app_key:${params.appId}`}, 0))`;
|
|
126
|
+
const [locked] = await db<{ id: string; status: AppKeyMetadata["accountStatus"] }[]>`
|
|
127
|
+
SELECT id, status FROM auth.service_accounts
|
|
128
|
+
WHERE kind = 'resource_bound' AND app_id = ${params.appId} AND resource_type = 'cloud.app' AND resource_id = ${params.appId}
|
|
129
|
+
ORDER BY created_at ASC
|
|
130
|
+
LIMIT 1
|
|
131
|
+
FOR UPDATE`;
|
|
132
|
+
if (!locked) return fail(err.notFound("Service account"));
|
|
133
|
+
const accountId = locked.id;
|
|
134
|
+
const accountStatus = locked.status;
|
|
135
|
+
const installationId = await getInstallationId(db);
|
|
136
|
+
const [existing] = await db<{ id: string; fingerprint: string; status: AppKeyMetadata["status"] }[]>`
|
|
137
|
+
SELECT c.id, c.fingerprint,
|
|
138
|
+
CASE WHEN c.status = 'revoked' THEN 'revoked' WHEN c.expires_at <= now() THEN 'expired' ELSE 'active' END AS status
|
|
139
|
+
FROM auth.service_account_credentials c
|
|
140
|
+
JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
141
|
+
WHERE sa.kind = 'resource_bound' AND sa.app_id = ${params.appId} AND sa.resource_type = 'cloud.app'
|
|
142
|
+
AND sa.resource_id = ${params.appId} AND c.kind = 'app_key' AND c.public_key = ${params.publicKey}
|
|
143
|
+
`;
|
|
144
|
+
// Revoked/expired keys remain idempotent and are never reactivated.
|
|
145
|
+
if (existing)
|
|
146
|
+
return ok({ keyId: existing.id, installationId, fingerprint: existing.fingerprint, status: existing.status, accountStatus });
|
|
147
|
+
if (accountStatus !== "active")
|
|
148
|
+
return fail({
|
|
149
|
+
...err.conflict("Service account"),
|
|
150
|
+
message: "The app service account is disabled; enable it before enrolling a new key",
|
|
151
|
+
});
|
|
152
|
+
if (expiresAt && expiresAt.getTime() <= Date.now()) return fail(err.badInput("Expiry must be in the future"));
|
|
153
|
+
const [count] = await db<{ active: number }[]>`
|
|
154
|
+
SELECT count(*)::int AS active FROM auth.service_account_credentials c
|
|
155
|
+
JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
156
|
+
WHERE sa.kind = 'resource_bound' AND sa.app_id = ${params.appId} AND sa.resource_type = 'cloud.app'
|
|
157
|
+
AND sa.resource_id = ${params.appId} AND c.kind = 'app_key' AND c.status = 'active'
|
|
158
|
+
AND (c.expires_at IS NULL OR c.expires_at > now())
|
|
159
|
+
`;
|
|
160
|
+
if ((count?.active ?? 0) >= 2)
|
|
161
|
+
return fail({ ...err.conflict("App key"), message: "An app can have at most two active keys; revoke an old key first" });
|
|
162
|
+
const [row] = await db<{ id: string }[]>`
|
|
163
|
+
INSERT INTO auth.service_account_credentials
|
|
164
|
+
(service_account_id, name, kind, public_key, fingerprint, created_by, expires_at)
|
|
165
|
+
VALUES (${accountId}::uuid, ${name}, 'app_key', ${params.publicKey}, ${fingerprint}, ${params.actor.id}::uuid, ${expiresAt})
|
|
166
|
+
RETURNING id
|
|
167
|
+
`;
|
|
168
|
+
if (!row) throw new Error("App key registration returned no key");
|
|
169
|
+
await audit.record(
|
|
170
|
+
{
|
|
171
|
+
action: "app_key.create",
|
|
172
|
+
outcome: "allowed",
|
|
173
|
+
actor: actorForAudit(params.actor),
|
|
174
|
+
target: { type: "service_account_credential", id: row.id, label: name },
|
|
175
|
+
metadata: { appId: params.appId, serviceAccountId: accountId, fingerprint },
|
|
176
|
+
},
|
|
177
|
+
db,
|
|
178
|
+
);
|
|
179
|
+
return ok({ keyId: row.id, installationId, fingerprint, status: "active" as const, accountStatus });
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export const listAppKeys = async (params: { actor: AdminActor; appId: string; page?: number; perPage?: number }) => {
|
|
184
|
+
const valid = validateAdmin(params.actor, params.appId);
|
|
185
|
+
if (!valid.ok) return valid;
|
|
186
|
+
const { page, perPage } = pageParams(params);
|
|
187
|
+
const rows = await sql<(Row & { total: number })[]>`
|
|
188
|
+
SELECT ${sql.unsafe(columns)}, count(*) OVER()::int AS total FROM auth.service_account_credentials c
|
|
189
|
+
JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
190
|
+
WHERE c.kind = 'app_key' AND sa.kind = 'resource_bound' AND sa.app_id = ${params.appId}
|
|
191
|
+
AND sa.resource_type = 'cloud.app' AND sa.resource_id = ${params.appId}
|
|
192
|
+
ORDER BY c.created_at DESC, c.id DESC LIMIT ${perPage} OFFSET ${(page - 1) * perPage}
|
|
193
|
+
`;
|
|
194
|
+
let total = rows[0]?.total ?? 0;
|
|
195
|
+
if (rows.length === 0 && page > 1) {
|
|
196
|
+
const [count] = await sql<{ total: number }[]>`
|
|
197
|
+
SELECT count(*)::int AS total FROM auth.service_account_credentials c
|
|
198
|
+
JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
199
|
+
WHERE c.kind = 'app_key' AND sa.kind = 'resource_bound' AND sa.app_id = ${params.appId}
|
|
200
|
+
AND sa.resource_type = 'cloud.app' AND sa.resource_id = ${params.appId}
|
|
201
|
+
`;
|
|
202
|
+
total = count?.total ?? 0;
|
|
203
|
+
}
|
|
204
|
+
return ok({ installationId: await getInstallationId(), items: rows.map(map), page, perPage, total, hasNext: page * perPage < total });
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export const listAppsWithKeys = async (params: { actor: AdminActor; page?: number; perPage?: number }) => {
|
|
208
|
+
const valid = validateAdmin(params.actor);
|
|
209
|
+
if (!valid.ok) return valid;
|
|
210
|
+
const { page, perPage } = pageParams(params);
|
|
211
|
+
const rows = await sql<
|
|
212
|
+
{ appId: string; accountStatus: AppKeyMetadata["accountStatus"]; totalKeys: number; activeKeys: number; total: number }[]
|
|
213
|
+
>`
|
|
214
|
+
SELECT sa.app_id AS "appId", sa.status AS "accountStatus", count(*)::int AS "totalKeys",
|
|
215
|
+
count(*) FILTER (WHERE c.status = 'active' AND (c.expires_at IS NULL OR c.expires_at > now()))::int AS "activeKeys",
|
|
216
|
+
count(*) OVER()::int AS total
|
|
217
|
+
FROM auth.service_account_credentials c JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
218
|
+
WHERE c.kind = 'app_key' AND sa.kind = 'resource_bound' AND sa.resource_type = 'cloud.app' AND sa.resource_id = sa.app_id
|
|
219
|
+
GROUP BY sa.app_id, sa.status ORDER BY sa.app_id LIMIT ${perPage} OFFSET ${(page - 1) * perPage}
|
|
220
|
+
`;
|
|
221
|
+
let total = rows[0]?.total ?? 0;
|
|
222
|
+
if (rows.length === 0 && page > 1) {
|
|
223
|
+
const [count] = await sql<{ total: number }[]>`
|
|
224
|
+
SELECT count(DISTINCT sa.app_id)::int AS total FROM auth.service_account_credentials c
|
|
225
|
+
JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
226
|
+
WHERE c.kind = 'app_key' AND sa.kind = 'resource_bound' AND sa.resource_type = 'cloud.app' AND sa.resource_id = sa.app_id
|
|
227
|
+
`;
|
|
228
|
+
total = count?.total ?? 0;
|
|
229
|
+
}
|
|
230
|
+
return ok({
|
|
231
|
+
installationId: await getInstallationId(),
|
|
232
|
+
items: rows.map(({ total: _, ...row }) => row),
|
|
233
|
+
page,
|
|
234
|
+
perPage,
|
|
235
|
+
total,
|
|
236
|
+
hasNext: page * perPage < total,
|
|
237
|
+
});
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
export const revokeAppKey = async (params: { actor: AdminActor; appId: string; keyId: string }): Promise<Result<void>> => {
|
|
241
|
+
const valid = validateAdmin(params.actor, params.appId);
|
|
242
|
+
if (!valid.ok) return valid;
|
|
243
|
+
if (params.keyId.length !== 36 || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(params.keyId))
|
|
244
|
+
return fail(err.notFound("App key"));
|
|
245
|
+
return sql.begin(async (db) => {
|
|
246
|
+
const [key] = await db<{ id: string; service_account_id: string; status: string; name: string; fingerprint: string }[]>`
|
|
247
|
+
SELECT c.id, c.service_account_id, c.status, c.name, c.fingerprint FROM auth.service_account_credentials c
|
|
248
|
+
JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
249
|
+
WHERE c.id = ${params.keyId}::uuid AND c.kind = 'app_key' AND sa.app_id = ${params.appId}
|
|
250
|
+
AND sa.kind = 'resource_bound' AND sa.resource_type = 'cloud.app' AND sa.resource_id = ${params.appId}
|
|
251
|
+
FOR UPDATE OF sa, c
|
|
252
|
+
`;
|
|
253
|
+
if (!key) return fail(err.notFound("App key"));
|
|
254
|
+
if (key.status === "revoked") return ok();
|
|
255
|
+
await db`UPDATE auth.service_account_credentials SET status = 'revoked', revoked_at = now(), revoked_by = ${params.actor.id}::uuid
|
|
256
|
+
WHERE id = ${key.id}::uuid`;
|
|
257
|
+
await audit.record(
|
|
258
|
+
{
|
|
259
|
+
action: "app_key.revoke",
|
|
260
|
+
outcome: "allowed",
|
|
261
|
+
actor: actorForAudit(params.actor),
|
|
262
|
+
target: { type: "service_account_credential", id: key.id, label: key.name },
|
|
263
|
+
metadata: { appId: params.appId, serviceAccountId: key.service_account_id, fingerprint: key.fingerprint },
|
|
264
|
+
},
|
|
265
|
+
db,
|
|
266
|
+
);
|
|
267
|
+
return ok();
|
|
268
|
+
});
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/** Bootstrap and signed workload requests must query this directly; no cache. */
|
|
272
|
+
export const getActiveAppKey = async (
|
|
273
|
+
params: { appId: string; keyId: string },
|
|
274
|
+
db: SQL = sql,
|
|
275
|
+
): Promise<(AppKeyMetadata & { connectionsAllowed: boolean }) | null> => {
|
|
276
|
+
try {
|
|
277
|
+
validateAppKeyAppId(params.appId);
|
|
278
|
+
} catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
if (params.keyId.length !== 36 || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(params.keyId))
|
|
282
|
+
return null;
|
|
283
|
+
const [row] = await db<(Row & { connections_allowed: boolean })[]>`
|
|
284
|
+
SELECT ${db.unsafe(columns)} FROM auth.service_account_credentials c
|
|
285
|
+
JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
286
|
+
WHERE c.kind = 'app_key' AND c.id = ${params.keyId}::uuid AND c.status = 'active'
|
|
287
|
+
AND (c.expires_at IS NULL OR c.expires_at > now()) AND sa.status = 'active'
|
|
288
|
+
AND sa.kind = 'resource_bound' AND sa.app_id = ${params.appId} AND sa.resource_type = 'cloud.app' AND sa.resource_id = ${params.appId}
|
|
289
|
+
`;
|
|
290
|
+
return row ? { ...map(row), connectionsAllowed: row.connections_allowed } : null;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
/** Call only after successful authentication; a late touch cannot revive a key. */
|
|
294
|
+
export const touchAppKey = async (params: { appId: string; keyId: string }, db: SQL = sql): Promise<void> => {
|
|
295
|
+
const key = await getActiveAppKey(params, db);
|
|
296
|
+
if (!key) return;
|
|
297
|
+
await db`UPDATE auth.service_account_credentials SET last_used_at = now() WHERE id = ${key.keyId}::uuid AND status = 'active'
|
|
298
|
+
AND (expires_at IS NULL OR expires_at > now())`;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
export const appKeys = { register: registerAppKey, list: listAppKeys, listApps: listAppsWithKeys, revoke: revokeAppKey };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { connections } from "../runtime/connections";
|
|
2
2
|
import { logger } from "./logging";
|
|
3
3
|
import { requestCacheRedis } from "./request-cache-redis";
|
|
4
4
|
|
|
@@ -31,7 +31,7 @@ export const createDownstreamBudget = (options: { client?: () => Promise<Client>
|
|
|
31
31
|
async acquire(input: { downstream: Downstream; timeoutMs: number; signal: AbortSignal }): Promise<() => void> {
|
|
32
32
|
input.signal.throwIfAborted();
|
|
33
33
|
if (!Number.isFinite(input.timeoutMs) || input.timeoutMs <= 0) throw new RangeError("Downstream timeout must be positive and finite");
|
|
34
|
-
const namespace = options.namespace?.() ??
|
|
34
|
+
const namespace = options.namespace?.() ?? connections().nats.namespace;
|
|
35
35
|
if (!namespace.trim()) throw new Error("SYNC_NAMESPACE is required for shared downstream budgets");
|
|
36
36
|
const key = `cloud:downstream:{${namespace}}:${input.downstream}`;
|
|
37
37
|
const token = crypto.randomUUID();
|
package/src/services/gateway.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { lazySync } from "../_internal/process-sync";
|
|
2
|
+
import type { EnvSettingStatus } from "../contracts/settings-env";
|
|
2
3
|
import { logger } from "./logging";
|
|
3
4
|
|
|
4
5
|
/** Written by the gateway router, read by gateway-ops: one owner, one declaration. */
|
|
@@ -24,6 +25,8 @@ export type GatewayRouteWarning = {
|
|
|
24
25
|
|
|
25
26
|
export type GatewayRouteSnapshotInput = {
|
|
26
27
|
instanceId: string;
|
|
28
|
+
/** Sanitized migration diagnostics; omitted by pre-migration gateways. */
|
|
29
|
+
envSettings?: EnvSettingStatus[];
|
|
27
30
|
baseUrl: string;
|
|
28
31
|
startedAt: number;
|
|
29
32
|
routeHash: string;
|
|
@@ -44,6 +47,8 @@ export type GatewayRouteSnapshotInput = {
|
|
|
44
47
|
|
|
45
48
|
export type GatewayRouteSnapshot = {
|
|
46
49
|
instanceId: string;
|
|
50
|
+
/** Sanitized migration diagnostics; omitted by pre-migration gateways. */
|
|
51
|
+
envSettings?: EnvSettingStatus[];
|
|
47
52
|
baseUrl: string;
|
|
48
53
|
startedAt: number;
|
|
49
54
|
updatedAt: number;
|
|
@@ -97,6 +102,7 @@ const snapshots = lazySync((sync) =>
|
|
|
97
102
|
|
|
98
103
|
export const buildGatewayRouteSnapshot = (input: GatewayRouteSnapshotInput): GatewayRouteSnapshot => ({
|
|
99
104
|
instanceId: input.instanceId,
|
|
105
|
+
...(input.envSettings ? { envSettings: input.envSettings } : {}),
|
|
100
106
|
baseUrl: input.baseUrl,
|
|
101
107
|
startedAt: input.startedAt,
|
|
102
108
|
updatedAt: Date.now(),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { getAppWorkloadCredential } from "../../_internal/workload-credential";
|
|
1
2
|
export {
|
|
2
3
|
CLOUD_INVOCATION_JWKS_PATH,
|
|
3
4
|
CLOUD_INVOCATION_PROTOCOL_VERSION,
|
|
@@ -37,6 +38,7 @@ export {
|
|
|
37
38
|
} from "./key-ring";
|
|
38
39
|
export { identityMetrics } from "./metrics";
|
|
39
40
|
export { isNestedSourceCurrent, type NestedInvocationLease, nestedInvocationLeases } from "./nested-invocation";
|
|
41
|
+
export { type ResourceReader, resolveResourceReaders } from "./resource-readers";
|
|
40
42
|
export { createIdentityPublicRoutes } from "./routes";
|
|
41
43
|
export {
|
|
42
44
|
type CloudSessionClaims,
|
|
@@ -51,3 +53,4 @@ export {
|
|
|
51
53
|
WORKLOAD_SCOPES,
|
|
52
54
|
type WorkloadScope,
|
|
53
55
|
} from "./workload-auth";
|
|
56
|
+
export { startAppWorkloadBroker } from "./workload-runtime";
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { type ResourcePrincipal, ResourcePrincipalSchema } from "../../contracts/capabilities";
|
|
4
|
+
import type { AccessSubject } from "../../contracts/shared";
|
|
5
|
+
import { decodeAccountCategoryEnabled } from "../account-category-policy";
|
|
6
|
+
import { toPgUuidArray } from "../postgres";
|
|
7
|
+
import type { ServiceAccount } from "../service-accounts";
|
|
8
|
+
|
|
9
|
+
/** Account authority only: no session, credential scopes, or content authority. */
|
|
10
|
+
export type ResourceReader = {
|
|
11
|
+
principal: ResourcePrincipal;
|
|
12
|
+
subject: AccessSubject;
|
|
13
|
+
binding: Pick<ServiceAccount, "appId" | "resourceType" | "resourceId"> | null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type UserRow = { id: string; category_enabled: string | null };
|
|
17
|
+
type AccountRow = {
|
|
18
|
+
id: string;
|
|
19
|
+
kind: ServiceAccount["kind"];
|
|
20
|
+
delegated_user_id: string | null;
|
|
21
|
+
app_id: string | null;
|
|
22
|
+
resource_type: string | null;
|
|
23
|
+
resource_id: string | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Resolve at most 500 current accounts, including delegated users and account-category policy.
|
|
27
|
+
* Grants and resource bindings remain the resource provider's responsibility.
|
|
28
|
+
*/
|
|
29
|
+
export const resolveResourceReaders = async (principals: readonly ResourcePrincipal[]): Promise<ResourceReader[]> => {
|
|
30
|
+
const input = z.array(ResourcePrincipalSchema).max(500).parse(principals);
|
|
31
|
+
if (!input.length) return [];
|
|
32
|
+
const userIds = input.flatMap((principal) => (principal.type === "user" ? [principal.userId] : []));
|
|
33
|
+
const accountIds = input.flatMap((principal) => (principal.type === "service_account" ? [principal.serviceAccountId] : []));
|
|
34
|
+
const accounts = await sql<AccountRow[]>`
|
|
35
|
+
SELECT id, kind, delegated_user_id, app_id, resource_type, resource_id
|
|
36
|
+
FROM auth.service_accounts WHERE id = ANY(${toPgUuidArray(accountIds)}::uuid[]) AND status = 'active'
|
|
37
|
+
`;
|
|
38
|
+
const users = await sql<UserRow[]>`
|
|
39
|
+
SELECT u.id, category.value AS category_enabled FROM auth.users u
|
|
40
|
+
LEFT JOIN settings.entries category ON category.key = 'user.category.' ||
|
|
41
|
+
CASE WHEN u.provider = 'ipa' THEN 'freeipa' WHEN u.profile = 'guest' THEN 'guest' ELSE 'login' END || '.enabled'
|
|
42
|
+
WHERE u.id = ANY(${toPgUuidArray([...userIds, ...accounts.flatMap((account) => (account.delegated_user_id ? [account.delegated_user_id] : []))])}::uuid[])
|
|
43
|
+
AND (u.account_expires IS NULL OR u.account_expires > clock_timestamp())
|
|
44
|
+
`;
|
|
45
|
+
const enabled = new Map<string | null, boolean>();
|
|
46
|
+
const allowedUsers = new Set<string>();
|
|
47
|
+
for (const user of users) {
|
|
48
|
+
if (!enabled.has(user.category_enabled)) enabled.set(user.category_enabled, await decodeAccountCategoryEnabled(user.category_enabled));
|
|
49
|
+
if (enabled.get(user.category_enabled)) allowedUsers.add(user.id);
|
|
50
|
+
}
|
|
51
|
+
const accountsById = new Map(accounts.map((account) => [account.id, account]));
|
|
52
|
+
return input.flatMap((principal): ResourceReader[] => {
|
|
53
|
+
if (principal.type === "user") {
|
|
54
|
+
const userId = principal.userId.toLowerCase();
|
|
55
|
+
return allowedUsers.has(userId) ? [{ principal, subject: { type: "user", userId }, binding: null }] : [];
|
|
56
|
+
}
|
|
57
|
+
const account = accountsById.get(principal.serviceAccountId.toLowerCase());
|
|
58
|
+
if (!account) return [];
|
|
59
|
+
if (account.kind === "user_delegated")
|
|
60
|
+
return account.delegated_user_id && allowedUsers.has(account.delegated_user_id)
|
|
61
|
+
? [
|
|
62
|
+
{
|
|
63
|
+
principal,
|
|
64
|
+
subject: { type: "user", userId: account.delegated_user_id, delegatedByServiceAccountId: account.id },
|
|
65
|
+
binding: null,
|
|
66
|
+
},
|
|
67
|
+
]
|
|
68
|
+
: [];
|
|
69
|
+
return [
|
|
70
|
+
{
|
|
71
|
+
principal,
|
|
72
|
+
subject: { type: "service_account", serviceAccountId: account.id },
|
|
73
|
+
binding:
|
|
74
|
+
account.kind === "resource_bound"
|
|
75
|
+
? { appId: account.app_id, resourceType: account.resource_type, resourceId: account.resource_id }
|
|
76
|
+
: null,
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
});
|
|
80
|
+
};
|
|
@@ -52,20 +52,28 @@ export const readIdentityGroupsAdmin = async (db: SQL): Promise<readonly string[
|
|
|
52
52
|
return Object.freeze(normalizeGroups(groups));
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
-
const resolveCoreJwksUrl = (
|
|
55
|
+
const resolveCoreJwksUrl = (
|
|
56
|
+
issuer: string,
|
|
57
|
+
path: string,
|
|
58
|
+
transportOrigin = env.CLOUD_IDENTITY_JWKS_ORIGIN ?? env.CLOUD_CORE_INTERNAL_ORIGIN,
|
|
59
|
+
): URL => {
|
|
56
60
|
if (!transportOrigin) return new URL(path, issuer);
|
|
57
61
|
const origin = new URL(transportOrigin);
|
|
58
62
|
if (origin.protocol !== "http:" && origin.protocol !== "https:") {
|
|
59
|
-
throw new Error("CLOUD_IDENTITY_JWKS_ORIGIN must use http or https");
|
|
63
|
+
throw new Error("Core JWKS origin (CLOUD_CORE_INTERNAL_ORIGIN or legacy CLOUD_IDENTITY_JWKS_ORIGIN) must use http or https");
|
|
60
64
|
}
|
|
61
65
|
return new URL(path, origin);
|
|
62
66
|
};
|
|
63
67
|
|
|
64
|
-
export const resolveSessionJwksUrl = (
|
|
65
|
-
|
|
68
|
+
export const resolveSessionJwksUrl = (
|
|
69
|
+
issuer: string,
|
|
70
|
+
transportOrigin = env.CLOUD_IDENTITY_JWKS_ORIGIN ?? env.CLOUD_CORE_INTERNAL_ORIGIN,
|
|
71
|
+
): URL => resolveCoreJwksUrl(issuer, CLOUD_SESSION_JWKS_PATH, transportOrigin);
|
|
66
72
|
|
|
67
|
-
export const resolveInvocationJwksUrl = (
|
|
68
|
-
|
|
73
|
+
export const resolveInvocationJwksUrl = (
|
|
74
|
+
issuer: string,
|
|
75
|
+
transportOrigin = env.CLOUD_IDENTITY_JWKS_ORIGIN ?? env.CLOUD_CORE_INTERNAL_ORIGIN,
|
|
76
|
+
): URL => resolveCoreJwksUrl(issuer, CLOUD_INVOCATION_JWKS_PATH, transportOrigin);
|
|
69
77
|
|
|
70
78
|
export const resolveOAuthJwksUrl = (issuer: string, transportOrigin = env.CLOUD_OAUTH_JWKS_ORIGIN): URL => {
|
|
71
79
|
if (!transportOrigin) return new URL(CLOUD_OAUTH_JWKS_PATH, issuer);
|