@sanoma-ai/workflows 0.10.3 → 0.10.7

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.
Files changed (70) hide show
  1. package/CLOUD-IDENTITY.md +128 -17
  2. package/README.md +15 -4
  3. package/dist/apply.d.ts +11 -4
  4. package/dist/apply.js +126 -35
  5. package/dist/apply.js.map +1 -1
  6. package/dist/call.js +1 -1
  7. package/dist/call.js.map +1 -1
  8. package/dist/cloud-identity.d.ts +5 -1
  9. package/dist/cloud-identity.js +13 -77
  10. package/dist/cloud-identity.js.map +1 -1
  11. package/dist/config.d.ts +3 -0
  12. package/dist/config.js +3 -2
  13. package/dist/config.js.map +1 -1
  14. package/dist/define.d.ts +9 -2
  15. package/dist/define.js +1 -0
  16. package/dist/define.js.map +1 -1
  17. package/dist/dns.d.ts +20 -0
  18. package/dist/dns.js +26 -0
  19. package/dist/dns.js.map +1 -0
  20. package/dist/drift.d.ts +4 -2
  21. package/dist/drift.js +13 -4
  22. package/dist/drift.js.map +1 -1
  23. package/dist/identity-broker.d.ts +14 -0
  24. package/dist/identity-broker.js +84 -0
  25. package/dist/identity-broker.js.map +1 -0
  26. package/dist/identity-provisioning.d.ts +29 -0
  27. package/dist/identity-provisioning.js +178 -0
  28. package/dist/identity-provisioning.js.map +1 -0
  29. package/dist/identity-service.d.ts +25 -0
  30. package/dist/identity-service.js +148 -0
  31. package/dist/identity-service.js.map +1 -0
  32. package/dist/index.d.ts +2 -1
  33. package/dist/index.js +1 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/op.d.ts +15 -8
  36. package/dist/op.js.map +1 -1
  37. package/dist/provisioning-identity.d.ts +27 -0
  38. package/dist/provisioning-identity.js +81 -0
  39. package/dist/provisioning-identity.js.map +1 -0
  40. package/dist/resource.d.ts +67 -6
  41. package/dist/resource.js +95 -9
  42. package/dist/resource.js.map +1 -1
  43. package/dist/resources.d.ts +3 -3
  44. package/dist/resources.js +6 -5
  45. package/dist/resources.js.map +1 -1
  46. package/dist/runtime-ops.d.ts +60 -1
  47. package/dist/runtime-ops.js +73 -1
  48. package/dist/runtime-ops.js.map +1 -1
  49. package/dist/scenario.d.ts +6 -3
  50. package/dist/scenario.js +13 -1
  51. package/dist/scenario.js.map +1 -1
  52. package/oxlint.json +1 -0
  53. package/package.json +5 -1
  54. package/src/apply.ts +165 -47
  55. package/src/call.ts +2 -2
  56. package/src/cloud-identity.ts +16 -76
  57. package/src/config.ts +6 -2
  58. package/src/define.ts +10 -2
  59. package/src/dns.ts +29 -0
  60. package/src/drift.ts +13 -4
  61. package/src/identity-broker.ts +94 -0
  62. package/src/identity-provisioning.ts +208 -0
  63. package/src/identity-service.ts +179 -0
  64. package/src/index.ts +2 -0
  65. package/src/op.ts +13 -3
  66. package/src/provisioning-identity.ts +106 -0
  67. package/src/resource.ts +147 -15
  68. package/src/resources.ts +6 -5
  69. package/src/runtime-ops.ts +79 -0
  70. package/src/scenario.ts +24 -6
@@ -0,0 +1,94 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { IdentityEnrollment, IdentityEnrollments, IdentityAudit } from "./cloud-identity.ts";
3
+
4
+ const CREDENTIAL = /^[A-Za-z0-9_-]{43}$/;
5
+ const ACCOUNT = /^[a-z][a-z0-9-]+@[a-z][a-z0-9-]+\.iam\.gserviceaccount\.com$/;
6
+ const credentialHash = (credential: string) => createHash("sha256").update(credential).digest("hex");
7
+
8
+ const validEnrollment = (e: IdentityEnrollment | undefined): e is IdentityEnrollment =>
9
+ !!e &&
10
+ [e.deployment, e.company, e.environment].every((v) => typeof v === "string" && v.length > 0 && v.length <= 256) &&
11
+ ACCOUNT.test(e.serviceAccount) &&
12
+ Number.isSafeInteger(e.generation) &&
13
+ e.generation > 0;
14
+ const reply = (status: number, body: unknown) =>
15
+ Response.json(body, {
16
+ status,
17
+ headers: { "Cache-Control": "no-store", Pragma: "no-cache" },
18
+ });
19
+
20
+ // Node/edge adapters can represent an empty POST as a stream. Reject actual bytes,
21
+ // without buffering attacker-controlled input or waiting indefinitely for its end.
22
+ async function hasBody(request: Request): Promise<boolean> {
23
+ if (!request.body) return false;
24
+ const reader = request.body.getReader();
25
+ let timeout: ReturnType<typeof setTimeout> | undefined;
26
+ try {
27
+ const first = await Promise.race([
28
+ reader.read(),
29
+ new Promise<never>((_, reject) => {
30
+ timeout = setTimeout(() => reject(new Error("Invalid broker request")), 5_000);
31
+ }),
32
+ ]);
33
+ return !first.done;
34
+ } finally {
35
+ clearTimeout(timeout);
36
+ void reader.cancel().catch(() => {});
37
+ }
38
+ }
39
+
40
+ /** Shared server-only issuance boundary. Public factories fix the route, audience and token kind. */
41
+ export function enrollmentBroker<T extends { expiresAt: number }>(options: {
42
+ path: string;
43
+ method: "GET" | "POST";
44
+ enrollments: IdentityEnrollments;
45
+ accept(enrollment: IdentityEnrollment): boolean;
46
+ issue(enrollment: IdentityEnrollment): Promise<T>;
47
+ valid(token: T, now: number): boolean;
48
+ body(token: T): unknown;
49
+ audit(event: IdentityAudit): Promise<void>;
50
+ }): (request: Request) => Promise<Response> {
51
+ return async (request) => {
52
+ let enrollment: IdentityEnrollment | undefined;
53
+ try {
54
+ const url = new URL(request.url);
55
+ if (url.pathname !== options.path || request.method !== options.method) return reply(404, { error: "not_found" });
56
+ if (url.search || (await hasBody(request))) return reply(400, { error: "invalid_request" });
57
+ const authorization = request.headers.get("authorization") ?? "";
58
+ const credential = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
59
+ const hash = CREDENTIAL.test(credential) ? credentialHash(credential) : undefined;
60
+ enrollment = hash ? await options.enrollments.active(hash) : undefined;
61
+ if (!validEnrollment(enrollment) || !options.accept(enrollment)) {
62
+ await options.audit({ result: "denied" });
63
+ return reply(401, { error: "unauthorized" });
64
+ }
65
+ // Copy authoritative values before any awaits; caller fields never select a target or lifetime.
66
+ enrollment = {
67
+ deployment: enrollment.deployment,
68
+ company: enrollment.company,
69
+ environment: enrollment.environment,
70
+ serviceAccount: enrollment.serviceAccount,
71
+ generation: enrollment.generation,
72
+ };
73
+ const token = await options.issue(enrollment);
74
+ if (!options.valid(token, Date.now())) throw new Error("Invalid issued token");
75
+ const current = await options.enrollments.active(hash!);
76
+ if (
77
+ !current ||
78
+ (Object.keys(enrollment) as (keyof IdentityEnrollment)[]).some((key) => current[key] !== enrollment![key])
79
+ ) {
80
+ await options.audit({ result: "denied", enrollment });
81
+ return reply(401, { error: "unauthorized" });
82
+ }
83
+ await options.audit({ result: "issued", enrollment, expiresAt: token.expiresAt });
84
+ return reply(200, options.body(token));
85
+ } catch {
86
+ try {
87
+ await options.audit({ result: "unavailable", ...(enrollment && { enrollment }) });
88
+ } catch {
89
+ /* fail closed */
90
+ }
91
+ return reply(503, { error: "unavailable" });
92
+ }
93
+ };
94
+ }
@@ -0,0 +1,208 @@
1
+ import type { Pool, PoolClient } from "pg";
2
+ import type { IdentityEnrollment } from "./cloud-identity.ts";
3
+
4
+ export type DeploymentIdentity = Omit<IdentityEnrollment, "generation">;
5
+ export interface EnrollIdentity extends IdentityEnrollment {
6
+ hash: string;
7
+ validUntil: string;
8
+ }
9
+
10
+ /** Provisioning authority only. The serving broker receives the separate read-only interface. */
11
+ export interface IdentityProvisioning {
12
+ enroll(input: EnrollIdentity): Promise<void>;
13
+ retire(input: DeploymentIdentity & { generation: number; validUntil: string }): Promise<void>;
14
+ revoke(input: DeploymentIdentity): Promise<void>;
15
+ }
16
+
17
+ export class IdentityProvisioningError extends Error {
18
+ readonly retryable: boolean;
19
+ readonly code: "invalid" | "conflict" | "unavailable";
20
+ constructor(code: "invalid" | "conflict" | "unavailable") {
21
+ super(`Identity provisioning: ${code}`);
22
+ this.name = "IdentityProvisioningError";
23
+ this.code = code;
24
+ this.retryable = code === "unavailable";
25
+ }
26
+ }
27
+
28
+ /** Recognize another package copy without ever forwarding arbitrary messages or code strings. */
29
+ export function identityProvisioningCode(error: unknown): IdentityProvisioningError["code"] | undefined {
30
+ if (!error || typeof error !== "object" || !("name" in error) || error.name !== "IdentityProvisioningError") return;
31
+ if (!("code" in error)) return;
32
+ switch (error.code) {
33
+ case "invalid":
34
+ return "invalid";
35
+ case "conflict":
36
+ return "conflict";
37
+ case "unavailable":
38
+ return "unavailable";
39
+ }
40
+ }
41
+
42
+ function fail(code: "invalid" | "conflict"): never {
43
+ throw new IdentityProvisioningError(code);
44
+ }
45
+ const sameIdentity = (a: DeploymentIdentity, b: DeploymentIdentity) =>
46
+ a.deployment === b.deployment &&
47
+ a.company === b.company &&
48
+ a.environment === b.environment &&
49
+ a.serviceAccount === b.serviceAccount;
50
+ const checkIdentity = (input: DeploymentIdentity) => {
51
+ if (
52
+ ![input.deployment, input.company, input.environment].every(
53
+ (s) => typeof s === "string" && s.length > 0 && s.length <= 256 && !s.includes("\0"),
54
+ ) ||
55
+ !/^[a-z][a-z0-9-]+@[a-z][a-z0-9-]+\.iam\.gserviceaccount\.com$/.test(input.serviceAccount)
56
+ )
57
+ fail("invalid");
58
+ };
59
+ const generationOf = (generation: number) => {
60
+ if (!Number.isSafeInteger(generation) || generation <= 0) fail("invalid");
61
+ };
62
+ const deadlineOf = (value: string) => {
63
+ const time = Date.parse(value);
64
+ if (!Number.isFinite(time)) fail("invalid");
65
+ return time;
66
+ };
67
+
68
+ /** Caller owns a dedicated writer pool; never give this pool to the broker or company code. */
69
+ export function postgresIdentityProvisioning(
70
+ database: () => Promise<Pick<Pool, "connect">>,
71
+ options: { maxValidityMs: number },
72
+ ): IdentityProvisioning {
73
+ const maximum = options.maxValidityMs;
74
+ if (!Number.isSafeInteger(maximum) || maximum <= 0) fail("invalid");
75
+ async function transaction(
76
+ input: DeploymentIdentity,
77
+ create: boolean,
78
+ run: (db: PoolClient, enabled: boolean, now: number) => Promise<void>,
79
+ ) {
80
+ checkIdentity(input);
81
+ let db: PoolClient | undefined;
82
+ try {
83
+ db = await (await database()).connect();
84
+ await db.query("BEGIN");
85
+ if (create)
86
+ await db.query(
87
+ `INSERT INTO sanoma_identity.deployments (id, company, environment, service_account)
88
+ VALUES ($1,$2,$3,$4) ON CONFLICT (id) DO NOTHING`,
89
+ [input.deployment, input.company, input.environment, input.serviceAccount],
90
+ );
91
+ // Every writer takes the deployment row lock: enrollment cannot race revocation/retirement.
92
+ const { rows } = await db.query<DeploymentIdentity & { enabled: boolean }>(
93
+ `SELECT id AS deployment, company, environment, service_account AS "serviceAccount", enabled
94
+ FROM sanoma_identity.deployments WHERE id=$1 FOR UPDATE`,
95
+ [input.deployment],
96
+ );
97
+ const row = rows[0];
98
+ if (!row || !sameIdentity(row, input)) fail("conflict");
99
+ const clock = await db.query<{ now: Date }>("SELECT clock_timestamp() AS now");
100
+ await run(db, row.enabled, clock.rows[0]!.now.getTime());
101
+ await db.query("COMMIT");
102
+ } catch (error) {
103
+ await db?.query("ROLLBACK").catch(() => {});
104
+ const code = identityProvisioningCode(error);
105
+ if (code) throw new IdentityProvisioningError(code);
106
+ if ((error as { code?: string })?.code === "23505") throw new IdentityProvisioningError("conflict");
107
+ throw new IdentityProvisioningError("unavailable");
108
+ } finally {
109
+ db?.release();
110
+ }
111
+ }
112
+ return {
113
+ async enroll(input) {
114
+ generationOf(input.generation);
115
+ const until = deadlineOf(input.validUntil);
116
+ if (!/^[0-9a-f]{64}$/.test(input.hash)) fail("invalid");
117
+ await transaction(input, true, async (db, enabled, now) => {
118
+ if (!enabled) fail("conflict");
119
+ if (until <= now || until - now > maximum) fail("invalid");
120
+ const { rows } = await db.query<{ generation: string; hash: string; valid_until: Date; revoked: boolean }>(
121
+ `SELECT generation, hash, valid_until, revoked FROM sanoma_identity.credentials
122
+ WHERE deployment=$1 ORDER BY generation DESC`,
123
+ [input.deployment],
124
+ );
125
+ const existing = rows.find((r) => Number(r.generation) === input.generation);
126
+ if (existing) {
127
+ // An exact retry is safe. Never extend a retired generation or revive a revoked one.
128
+ if (existing.hash !== input.hash || existing.revoked || existing.valid_until.getTime() !== until)
129
+ fail("conflict");
130
+ return;
131
+ }
132
+ if (rows[0] && Number(rows[0].generation) >= input.generation) fail("conflict");
133
+ await db.query(
134
+ `INSERT INTO sanoma_identity.credentials (hash, deployment, generation, valid_until)
135
+ VALUES ($1,$2,$3,$4)`,
136
+ [input.hash, input.deployment, input.generation, new Date(until)],
137
+ );
138
+ });
139
+ },
140
+ async retire(input) {
141
+ generationOf(input.generation);
142
+ const until = deadlineOf(input.validUntil);
143
+ await transaction(input, false, async (db) => {
144
+ // Retirement only shortens access. The predicate protects the schema's finite interval.
145
+ const result = await db.query(
146
+ `UPDATE sanoma_identity.credentials SET
147
+ revoked=revoked OR $3::timestamptz <= clock_timestamp(),
148
+ valid_until=CASE WHEN $3::timestamptz > valid_from THEN LEAST(valid_until,$3::timestamptz) ELSE valid_until END
149
+ WHERE deployment=$1 AND generation=$2`,
150
+ [input.deployment, input.generation, new Date(until)],
151
+ );
152
+ if (!result.rowCount) fail("conflict");
153
+ });
154
+ },
155
+ async revoke(input) {
156
+ await transaction(input, false, async (db) => {
157
+ // Terminal for this deployment id. A new enrollment cannot silently re-enable it.
158
+ await db.query("UPDATE sanoma_identity.deployments SET enabled=false WHERE id=$1", [input.deployment]);
159
+ });
160
+ },
161
+ };
162
+ }
163
+
164
+ /** Run-local sandbox authority: never reads or mutates the live enrollment database. */
165
+ export function memoryIdentityProvisioning(): IdentityProvisioning {
166
+ const deployments = new Map<
167
+ string,
168
+ { identity: DeploymentIdentity; enabled: boolean; credentials: Map<number, EnrollIdentity> }
169
+ >();
170
+ const hashes = new Set<string>();
171
+ const find = (input: DeploymentIdentity) => {
172
+ checkIdentity(input);
173
+ const found = deployments.get(input.deployment);
174
+ if (!found || !sameIdentity(found.identity, input)) return fail("conflict");
175
+ return found;
176
+ };
177
+ return {
178
+ async enroll(input) {
179
+ checkIdentity(input);
180
+ generationOf(input.generation);
181
+ if (deadlineOf(input.validUntil) <= Date.now() || !/^[0-9a-f]{64}$/.test(input.hash)) fail("invalid");
182
+ const found = deployments.get(input.deployment);
183
+ if (found && (!sameIdentity(found.identity, input) || !found.enabled)) fail("conflict");
184
+ const current = found?.credentials.get(input.generation);
185
+ if (current) {
186
+ if (current.hash !== input.hash || deadlineOf(current.validUntil) !== deadlineOf(input.validUntil))
187
+ fail("conflict");
188
+ return;
189
+ }
190
+ if (hashes.has(input.hash) || (found && [...found.credentials.keys()].some((g) => g >= input.generation)))
191
+ fail("conflict");
192
+ const row = found ?? { identity: { ...input }, enabled: true, credentials: new Map<number, EnrollIdentity>() };
193
+ row.credentials.set(input.generation, { ...input });
194
+ hashes.add(input.hash);
195
+ deployments.set(input.deployment, row);
196
+ },
197
+ async retire(input) {
198
+ generationOf(input.generation);
199
+ const deadline = deadlineOf(input.validUntil);
200
+ const current = find(input).credentials.get(input.generation);
201
+ if (!current) return fail("conflict");
202
+ current.validUntil = new Date(Math.min(deadlineOf(current.validUntil), deadline)).toISOString();
203
+ },
204
+ async revoke(input) {
205
+ find(input).enabled = false;
206
+ },
207
+ };
208
+ }
@@ -0,0 +1,179 @@
1
+ import { createServer } from "node:http";
2
+ import type { AddressInfo } from "node:net";
3
+ import { Pool } from "pg";
4
+ import { NodeRequest, sendNodeResponse } from "srvx/node";
5
+ import {
6
+ identityBroker,
7
+ postgresIdentityEnrollments,
8
+ attachedGoogleTokenIssuer,
9
+ provisioningIdentityBroker,
10
+ attachedGoogleIdTokenIssuer,
11
+ type ProvisioningIdentity,
12
+ type IdentityAudit,
13
+ } from "./cloud-identity.ts";
14
+
15
+ /** Install with administrative authority in the audit database, separately from the serving process. */
16
+ export const CLOUD_IDENTITY_AUDIT_SQL = `
17
+ CREATE SCHEMA IF NOT EXISTS sanoma_identity;
18
+ REVOKE ALL ON SCHEMA sanoma_identity FROM PUBLIC;
19
+ CREATE TABLE IF NOT EXISTS sanoma_identity.audit_events (
20
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
21
+ recorded_at timestamptz NOT NULL DEFAULT clock_timestamp(),
22
+ result text NOT NULL CHECK (result IN ('issued', 'denied', 'unavailable')),
23
+ deployment text, company text, environment text, service_account text,
24
+ generation bigint, expires_at timestamptz
25
+ );
26
+ REVOKE ALL ON sanoma_identity.audit_events FROM PUBLIC;
27
+ `;
28
+
29
+ export interface IdentityServiceOptions {
30
+ /** Explicit broker database login with SELECT only on the enrollment tables. */
31
+ enrollmentDatabaseUrl: string;
32
+ /** Explicit audit database login with INSERT only on audit_events. */
33
+ auditDatabaseUrl: string;
34
+ mode: { type: "runtime" } | { type: "provisioning"; identity: ProvisioningIdentity };
35
+ /** Cloud Run terminates TLS. Keep this service behind the deployment's HTTPS ingress. */
36
+ host?: string;
37
+ port?: number;
38
+ /** Bound in-flight issuance work and database waiters; excess requests receive 429. */
39
+ maxConcurrentRequests?: number;
40
+ }
41
+
42
+ const unavailable = () => new Error("Identity service unavailable");
43
+ const poolFor = (connectionString: string) => {
44
+ if (!connectionString) throw unavailable();
45
+ const pool = new Pool({
46
+ connectionString,
47
+ max: 5,
48
+ connectionTimeoutMillis: 10_000,
49
+ query_timeout: 10_000,
50
+ statement_timeout: 10_000,
51
+ });
52
+ // Never forward database diagnostics: they can contain connection information.
53
+ pool.on("error", () => {});
54
+ return pool;
55
+ };
56
+
57
+ async function checkRoles(reader: Pool, audit: Pool): Promise<void> {
58
+ const read = await reader.query<{ allowed: boolean }>(`SELECT
59
+ has_schema_privilege('sanoma_identity', 'USAGE') AND
60
+ NOT has_schema_privilege('sanoma_identity', 'CREATE') AND
61
+ NOT EXISTS (SELECT FROM pg_roles WHERE rolname = current_user AND (rolsuper OR rolcreaterole OR rolcreatedb OR rolbypassrls)) AND
62
+ has_table_privilege('sanoma_identity.deployments', 'SELECT') AND
63
+ has_table_privilege('sanoma_identity.credentials', 'SELECT') AND
64
+ NOT has_table_privilege('sanoma_identity.deployments', 'INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER') AND
65
+ NOT has_table_privilege('sanoma_identity.credentials', 'INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER') AS allowed`);
66
+ const write = await audit.query<{ allowed: boolean }>(`SELECT
67
+ has_schema_privilege('sanoma_identity', 'USAGE') AND
68
+ NOT has_schema_privilege('sanoma_identity', 'CREATE') AND
69
+ NOT EXISTS (SELECT FROM pg_roles WHERE rolname = current_user AND (rolsuper OR rolcreaterole OR rolcreatedb OR rolbypassrls)) AND
70
+ has_table_privilege('sanoma_identity.audit_events', 'INSERT') AND
71
+ NOT has_table_privilege('sanoma_identity.audit_events', 'SELECT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER') AS allowed`);
72
+ if (read.rows[0]?.allowed !== true || write.rows[0]?.allowed !== true) throw unavailable();
73
+ }
74
+
75
+ /** Starts a standalone broker without loading company code, starting a worker or installing schemas. */
76
+ export async function startIdentityService(options: IdentityServiceOptions): Promise<{
77
+ url: string;
78
+ stop(): Promise<void>;
79
+ }> {
80
+ let reader: Pool | undefined, audit: Pool | undefined;
81
+ const server = createServer();
82
+ let closing: Promise<void> | undefined;
83
+ const close = () =>
84
+ (closing ??= (async () => {
85
+ await new Promise<void>((resolve) => {
86
+ server.close(() => resolve());
87
+ server.closeAllConnections();
88
+ });
89
+ await Promise.all([reader?.end(), audit?.end()]);
90
+ })());
91
+ try {
92
+ const limit = options.maxConcurrentRequests ?? 32;
93
+ if (!Number.isInteger(limit) || limit < 1 || limit > 256) throw unavailable();
94
+ let active = 0;
95
+ reader = poolFor(options.enrollmentDatabaseUrl);
96
+ audit = poolFor(options.auditDatabaseUrl);
97
+ await checkRoles(reader, audit);
98
+ const record = async (event: IdentityAudit) => {
99
+ const e = event.enrollment;
100
+ // Fixed columns only: no tokens, hashes, headers or arbitrary caller fields.
101
+ await audit!.query(
102
+ `INSERT INTO sanoma_identity.audit_events
103
+ (result, deployment, company, environment, service_account, generation, expires_at)
104
+ VALUES ($1,$2,$3,$4,$5,$6,$7)`,
105
+ [
106
+ event.result,
107
+ e?.deployment ?? null,
108
+ e?.company ?? null,
109
+ e?.environment ?? null,
110
+ e?.serviceAccount ?? null,
111
+ e?.generation ?? null,
112
+ event.expiresAt === undefined ? null : new Date(event.expiresAt),
113
+ ],
114
+ );
115
+ };
116
+ const enrollments = postgresIdentityEnrollments(async () => reader!);
117
+ const handle =
118
+ options.mode.type === "runtime"
119
+ ? identityBroker({ enrollments, issue: attachedGoogleTokenIssuer(), audit: record })
120
+ : options.mode.type === "provisioning"
121
+ ? provisioningIdentityBroker({
122
+ identity: options.mode.identity,
123
+ enrollments,
124
+ issue: attachedGoogleIdTokenIssuer(options.mode.identity),
125
+ audit: record,
126
+ })
127
+ : (() => {
128
+ throw unavailable();
129
+ })();
130
+ server.requestTimeout = 15_000;
131
+ server.headersTimeout = 10_000;
132
+ server.on("request", (req, res) => {
133
+ // Fetch Request adapters may hide a GET body. Refuse it at the HTTP boundary.
134
+ if (
135
+ req.method === "GET" &&
136
+ (req.headers["transfer-encoding"] || (req.headers["content-length"] ?? "0") !== "0")
137
+ ) {
138
+ res.writeHead(400, { "Content-Type": "application/json", "Cache-Control": "no-store" });
139
+ res.end('{"error":"invalid_request"}');
140
+ return;
141
+ }
142
+ if (req.url === "/healthz" && (req.method === "GET" || req.method === "HEAD")) {
143
+ res.writeHead(200, { "Content-Type": "text/plain", "Cache-Control": "no-store" });
144
+ res.end("ok\n");
145
+ return;
146
+ }
147
+ if (active >= limit) {
148
+ res.writeHead(429, { "Content-Type": "application/json", "Cache-Control": "no-store", "Retry-After": "1" });
149
+ res.end('{"error":"busy"}');
150
+ return;
151
+ }
152
+ active++;
153
+ Promise.resolve()
154
+ .then(() => handle(new NodeRequest({ req, res })))
155
+ .then((response) => sendNodeResponse(res, response))
156
+ .catch(() => {
157
+ if (res.headersSent) res.destroy();
158
+ else {
159
+ res.writeHead(503, { "Content-Type": "application/json", "Cache-Control": "no-store" });
160
+ res.end('{"error":"unavailable"}');
161
+ }
162
+ })
163
+ .finally(() => {
164
+ active--;
165
+ });
166
+ });
167
+ await new Promise<void>((resolve, reject) => {
168
+ server.once("error", reject);
169
+ server.listen(options.port ?? 8080, options.host ?? "127.0.0.1", resolve);
170
+ });
171
+ const { address, family, port } = server.address() as AddressInfo;
172
+ const hostname =
173
+ address === "0.0.0.0" || address === "::" ? "localhost" : family === "IPv6" ? `[${address}]` : address;
174
+ return { url: `http://${hostname}:${port}`, stop: close };
175
+ } catch {
176
+ await close().catch(() => {});
177
+ throw unavailable();
178
+ }
179
+ }
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ export {
25
25
  type ResourceGroup,
26
26
  retryableStatus,
27
27
  type SignInMethod,
28
+ type SocialClients,
28
29
  type VendorInfo,
29
30
  withSetup,
30
31
  } from "./op.ts";
@@ -60,6 +61,7 @@ export {
60
61
  type ResourceSpec,
61
62
  withoutWriteOnly,
62
63
  } from "./resource.ts";
64
+ export { type DnsRecord, dnsRecord } from "./dns.ts";
63
65
  export {
64
66
  declaredWakeup,
65
67
  defineConfig,
package/src/op.ts CHANGED
@@ -376,6 +376,11 @@ export type LoginMethod = "code" | "github" | "invite" | "password";
376
376
  /** How a person signed in to the staff login, which the ledger records (`signin.recorded`): all but a password. */
377
377
  export type SignInMethod = Exclude<LoginMethod, "password">;
378
378
 
379
+ /** The company's own OAuth clients, as a login takes them: GitHub's is its GitHub App's client. */
380
+ export interface SocialClients {
381
+ github?: { clientId: string; clientSecret: string };
382
+ }
383
+
379
384
  /**
380
385
  * What the app gives a connector's login half (`ConnectorSetup.login`) when it makes it: for a
381
386
  * public site, once per site; for the staff login, once, with `staff`.
@@ -405,12 +410,17 @@ export interface LoginContext {
405
410
  * sender, and then the login offers no code.
406
411
  */
407
412
  sendCode?(email: string, code: string): Promise<void>;
413
+ /**
414
+ * For a public site's login: the company's own OAuth clients (its GitHub App's), each a method
415
+ * when given, by which a visitor signs in or, signed in, links that account (`LoginIdentity.github`).
416
+ */
417
+ social?: SocialClients;
408
418
  /** For the staff login only: who it may let in, the company's social clients, and where it tells of a sign-in. */
409
419
  staff?: {
410
420
  /** Whether the people files declare the person `identity` names; a login never makes a user for anyone else. */
411
421
  admits(identity: LoginIdentity): boolean;
412
422
  /** The company's own OAuth clients, each a method when given. */
413
- social: { github?: { clientId: string; clientSecret: string } };
423
+ social: SocialClients;
414
424
  /** Tells the app of a sign-in by `method` of the person `identity` names, which the ledger records. Must not throw. */
415
425
  record(method: SignInMethod, identity: LoginIdentity): Promise<void>;
416
426
  };
@@ -453,8 +463,8 @@ export function sameOrigin(request: Request): boolean {
453
463
  export interface LoginHalf {
454
464
  /**
455
465
  * Who signed in, as the provider's stable subject, with their email when the provider verified
456
- * it and their GitHub login when they signed in with GitHub; null for a missing or bad cookie,
457
- * for which it never throws.
466
+ * it and their GitHub login when they signed in with GitHub or linked it; null for a missing or
467
+ * bad cookie, for which it never throws.
458
468
  */
459
469
  verify(request: Request): Promise<{ subject: string; email?: string; github?: string } | null>;
460
470
  /** Answers its own routes, `/auth/<vendor>/*`; undefined for any other request. */
@@ -0,0 +1,106 @@
1
+ import { Compute } from "google-auth-library";
2
+ import { enrollmentBroker } from "./identity-broker.ts";
3
+ import type { IdentityAudit, IdentityEnrollments } from "./cloud-identity.ts";
4
+
5
+ /** Fixed by the trusted provisioning issuer deployment, never by a request or company config. */
6
+ export interface ProvisioningIdentity {
7
+ serviceAccount: string;
8
+ /** Google's immutable numeric uniqueId for the source service account. */
9
+ subject: string;
10
+ /** Exact WIF provider audience whose trust condition pins this subject. */
11
+ audience: string;
12
+ }
13
+ export interface ProvisioningIdToken {
14
+ idToken: string;
15
+ expiresAt: number;
16
+ }
17
+
18
+ const ACCOUNT = /^[a-z][a-z0-9-]+@[a-z][a-z0-9-]+\.iam\.gserviceaccount\.com$/;
19
+ const AUDIENCE =
20
+ /^\/\/iam\.googleapis\.com\/projects\/[1-9][0-9]+\/locations\/global\/workloadIdentityPools\/[a-z][a-z0-9-]+\/providers\/[a-z][a-z0-9-]+$/;
21
+ const SCOPE = "https://www.googleapis.com/auth/cloud-platform";
22
+ const fail = () => new Error("Provisioning identity unavailable");
23
+
24
+ function identityOf(identity: ProvisioningIdentity): ProvisioningIdentity {
25
+ if (
26
+ !ACCOUNT.test(identity.serviceAccount) ||
27
+ !/^[1-9][0-9]+$/.test(identity.subject) ||
28
+ !AUDIENCE.test(identity.audience)
29
+ )
30
+ throw fail();
31
+ return { serviceAccount: identity.serviceAccount, subject: identity.subject, audience: identity.audience };
32
+ }
33
+
34
+ // The issuer calls Google's authenticated HTTPS endpoint. Check its fixed claims and expiry;
35
+ // signature verification of the returned Google token remains Google's STS responsibility.
36
+ function tokenExpiry(idToken: string, identity: ProvisioningIdentity, now: number): number {
37
+ try {
38
+ if (typeof idToken !== "string" || idToken.length > 8192) throw fail();
39
+ const parts = idToken.split(".");
40
+ if (parts.length !== 3 || !parts.every((part) => /^[A-Za-z0-9_-]+$/.test(part))) throw fail();
41
+ const claims = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8"));
42
+ if (
43
+ claims.iss !== "https://accounts.google.com" ||
44
+ claims.sub !== identity.subject ||
45
+ claims.aud !== identity.audience ||
46
+ claims.email !== identity.serviceAccount ||
47
+ claims.email_verified !== true ||
48
+ !Number.isSafeInteger(claims.iat) ||
49
+ !Number.isSafeInteger(claims.exp) ||
50
+ claims.iat * 1000 > now + 30_000 ||
51
+ claims.exp <= claims.iat ||
52
+ claims.exp - claims.iat > 3600 ||
53
+ claims.exp * 1000 <= now + 60_000 ||
54
+ claims.exp * 1000 > now + 3_630_000
55
+ )
56
+ throw fail();
57
+ return claims.exp * 1000;
58
+ } catch {
59
+ throw fail();
60
+ }
61
+ }
62
+
63
+ /** Dedicated Cloud Run attached identity with getOpenIdToken on the fixed source account only. */
64
+ export function attachedGoogleIdTokenIssuer(configuration: ProvisioningIdentity): () => Promise<ProvisioningIdToken> {
65
+ const identity = identityOf(configuration);
66
+ const auth = new Compute({ scopes: [SCOPE] });
67
+ return async () => {
68
+ try {
69
+ const response = await auth.request<{ token: string }>({
70
+ url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${identity.serviceAccount}:generateIdToken`,
71
+ method: "POST",
72
+ data: { audience: identity.audience, includeEmail: true },
73
+ timeout: 10_000,
74
+ });
75
+ const idToken = response.data.token;
76
+ return { idToken, expiresAt: tokenExpiry(idToken, identity, Date.now()) };
77
+ } catch {
78
+ throw fail();
79
+ }
80
+ };
81
+ }
82
+
83
+ /**
84
+ * GET /identity for a separate provisioning credential source. Deploy with its own enrollment
85
+ * reader and attached issuer, outside the runtime broker. OIDC tokens last up to one hour:
86
+ * revocation must remove federation/impersonation trust as well as stopping new issuance.
87
+ */
88
+ export function provisioningIdentityBroker(options: {
89
+ identity: ProvisioningIdentity;
90
+ enrollments: IdentityEnrollments;
91
+ /** Trusted Google issuer. The request cannot select subject, audience or token lifetime. */
92
+ issue(): Promise<ProvisioningIdToken>;
93
+ audit(event: IdentityAudit): Promise<void>;
94
+ }): (request: Request) => Promise<Response> {
95
+ const identity = identityOf(options.identity);
96
+ return enrollmentBroker({
97
+ path: "/identity",
98
+ method: "GET",
99
+ enrollments: options.enrollments,
100
+ accept: (enrollment) => enrollment.serviceAccount === identity.serviceAccount,
101
+ issue: () => options.issue(),
102
+ valid: (token, now) => token.expiresAt === tokenExpiry(token.idToken, identity, now),
103
+ body: (token) => ({ idToken: token.idToken, expiresAt: token.expiresAt }),
104
+ audit: options.audit,
105
+ });
106
+ }