-
Notifications
You must be signed in to change notification settings - Fork 3
✨ server: add allower #839
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@exactly/server": patch | ||
| --- | ||
|
|
||
| ✨ allow accounts on firewall after kyc |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@exactly/server": patch | ||
| --- | ||
|
|
||
| ✨ setup gcp credentials and kms |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -176,6 +176,7 @@ | |
| "valibot", | ||
| "valierror", | ||
| "valkey", | ||
| "valora", | ||
| "viem", | ||
| "viewability", | ||
| "vite", | ||
|
|
||
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,9 +21,11 @@ import { | |
| union, | ||
| } from "valibot"; | ||
|
|
||
| import { firewallAbi, firewallAddress } from "@exactly/common/generated/chain"; | ||
| import { Address } from "@exactly/common/validation"; | ||
|
|
||
| import database, { credentials } from "../database/index"; | ||
| import { kms } from "../utils/gcp"; | ||
| import { createUser } from "../utils/panda"; | ||
| import { addCapita, deriveAssociateId } from "../utils/pax"; | ||
| import { | ||
|
|
@@ -300,6 +302,22 @@ export default new Hono().post( | |
| if (risk.level === "very_high") return c.json({ code: "very high risk" }, 200); | ||
| } | ||
|
|
||
| if (firewallAddress) { | ||
| const account = safeParse(Address, credential.account); | ||
| if (account.success) { | ||
| const address = firewallAddress; | ||
| kms("allower") | ||
| .then((allower) => | ||
| allower.exaSend( | ||
| { name: "firewall.allow", op: "exa.firewall", attributes: { account: account.output } }, | ||
| { address, functionName: "allow", args: [account.output, true], abi: firewallAbi }, | ||
|
Comment on lines
+309
to
+313
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This Useful? React with 👍 / 👎. |
||
| { ignore: [`AlreadyAllowed(${account.output})`] }, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: The error handling logic compares a checksummed address ( Suggested FixEnsure both addresses in the comparison are in the same format. Either apply Prompt for AI AgentDid we get this right? 👍 / 👎 to inform future reviews. |
||
| ), | ||
| ) | ||
| .catch((error: unknown) => captureException(error, { level: "error" })); | ||
| } | ||
| } | ||
|
Comment on lines
+305
to
+319
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚩 Fire-and-forget firewall allow becomes non-retriable after pandaId is set The firewall Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| // TODO implement error handling to return 200 if event should not be retried | ||
| const { id } = await createUser({ | ||
| accountPurpose: fields.accountPurpose.value, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { access, writeFile } from "node:fs/promises"; | ||
| import { beforeEach, describe, expect, it, vi } from "vitest"; | ||
|
|
||
| vi.mock("node:fs/promises", () => ({ | ||
| writeFile: vi.fn(), | ||
| access: vi.fn(), | ||
| })); | ||
|
|
||
| vi.mock("@google-cloud/kms", () => ({ KeyManagementServiceClient: vi.fn() })); | ||
| vi.mock("@valora/viem-account-hsm-gcp", () => ({ gcpHsmToAccount: vi.fn().mockResolvedValue({}) })); | ||
|
|
||
| const mockWriteFile = vi.mocked(writeFile); | ||
| const mockAccess = vi.mocked(access); | ||
|
|
||
| describe("kms", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| vi.resetModules(); | ||
| mockAccess.mockRejectedValue(new Error("not found")); | ||
| }); | ||
|
|
||
| it("writes credentials with secure permissions", async () => { | ||
| const { kms } = await import("../../utils/gcp"); | ||
| await kms("allower"); | ||
|
|
||
| expect(mockWriteFile).toHaveBeenCalledWith("/tmp/gcp-service-account.json", expect.any(String), { mode: 0o600 }); | ||
| }); | ||
|
|
||
| it("skips writing when credentials already exist", async () => { | ||
| mockAccess.mockResolvedValue(); | ||
| const { kms } = await import("../../utils/gcp"); | ||
| await kms("allower"); | ||
|
|
||
| expect(mockWriteFile).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("caches credentials across calls", async () => { | ||
| const { kms } = await import("../../utils/gcp"); | ||
| await Promise.all([kms("allower"), kms("allower")]); | ||
|
|
||
| expect(mockWriteFile).toHaveBeenCalledTimes(1); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import { KeyManagementServiceClient } from "@google-cloud/kms"; | ||
| import { gcpHsmToAccount } from "@valora/viem-account-hsm-gcp"; | ||
| import { access, writeFile } from "node:fs/promises"; | ||
| import { parse } from "valibot"; | ||
| import { createWalletClient, http } from "viem"; | ||
|
|
||
| import alchemyAPIKey from "@exactly/common/alchemyAPIKey"; | ||
| import chain from "@exactly/common/generated/chain"; | ||
|
|
||
| import { extender } from "./keeper"; | ||
| import nonceManager from "./nonceManager"; | ||
| import { captureRequests, Requests } from "./publicClient"; | ||
|
|
||
| const DECODE_DEPTH = 3; | ||
| const CREDENTIALS_PATH = "/tmp/gcp-service-account.json"; | ||
|
|
||
| if (!process.env.GCP_BASE64_JSON) throw new Error("missing gcp base64 json"); | ||
| const encoded = process.env.GCP_BASE64_JSON; | ||
|
|
||
| if (!process.env.GCP_PROJECT_ID) throw new Error("missing gcp project id"); | ||
| if (!process.env.GCP_KMS_KEY_RING) throw new Error("missing gcp kms key ring"); | ||
| if (!process.env.GCP_KMS_KEY_VERSION) throw new Error("missing gcp kms key version"); | ||
| const projectId = process.env.GCP_PROJECT_ID; | ||
| const keyRing = process.env.GCP_KMS_KEY_RING; | ||
| const version = process.env.GCP_KMS_KEY_VERSION; | ||
|
|
||
| let pending: null | Promise<string> = null; | ||
|
|
||
| function setupCredentials() { | ||
| return (pending ??= (async () => { | ||
| const exists = await access(CREDENTIALS_PATH) | ||
| .then(() => true) | ||
| .catch(() => false); | ||
| if (!exists) { | ||
| let json = encoded; | ||
| for (let index = 0; index < DECODE_DEPTH; index++) { | ||
| json = Buffer.from(json, "base64").toString("utf8"); | ||
| } | ||
| await writeFile(CREDENTIALS_PATH, json, { mode: 0o600 }); | ||
| } | ||
| return CREDENTIALS_PATH; | ||
| })().catch((error: unknown) => { | ||
| pending = null; | ||
| throw error; | ||
| })); | ||
| } | ||
|
|
||
| // eslint-disable-next-line import/prefer-default-export | ||
| export async function kms(key: string) { | ||
| const account = await gcpHsmToAccount({ | ||
| hsmKeyVersion: `projects/${projectId}/locations/us-west2/keyRings/${keyRing}/cryptoKeys/${key}/cryptoKeyVersions/${version}`, | ||
| kmsClient: new KeyManagementServiceClient({ keyFilename: await setupCredentials() }), | ||
| }); | ||
| account.nonceManager = nonceManager; | ||
| return extender( | ||
| createWalletClient({ | ||
| chain, | ||
| transport: http(`${chain.rpcUrls.alchemy.http[0]}/${alchemyAPIKey}`, { | ||
| batch: true, | ||
| async onFetchRequest(request) { | ||
| captureRequests(parse(Requests, await request.json())); | ||
| }, | ||
| }), | ||
| account, | ||
| }), | ||
| ); | ||
| } | ||
|
Comment on lines
+49
to
+67
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 kms() creates a new KMS client and fetches public key on every invocation — no caching Every call to
The public key for a given KMS key version never changes, so the resolved account and client could be cached (e.g., in a Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
Uh oh!
There was an error while loading. Please reload this page.