diff --git a/prisma/schema/acl.prisma b/prisma/schema/acl.prisma new file mode 100644 index 00000000..82b1c801 --- /dev/null +++ b/prisma/schema/acl.prisma @@ -0,0 +1,37 @@ +// prisma/schema/acl.prisma +// On-chain ACL whitelist controlling which external contracts are +// permitted to interact with the platform, and which functions they may +// call (#966). Mutated only through the admin API, gated by 2-of-3 admin +// multisig authorization (see acl.service.ts). + +/// One row per whitelisted external contract, with the set of functions +/// it is permitted to call. `contractAddress` is unique — a contract is +/// either whitelisted (one row) or not. +model AclWhitelist { + id String @id @default(cuid()) + contractAddress String @unique + permittedFunctions String[] + addedBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([createdAt]) + @@map("acl_whitelist") +} + +/// Append-only audit trail of ACL whitelist add/remove events, independent +/// of the current AclWhitelist rows so history survives removal. Powers +/// GET /admin/acl/history. +model AclEvent { + id String @id @default(cuid()) + eventType String // "added" | "removed" + contractAddress String + permittedFunctions String[] + actor String + signers String[] + createdAt DateTime @default(now()) + + @@index([contractAddress]) + @@index([createdAt]) + @@map("acl_events") +} diff --git a/prisma/schema/migrations/20260926120000_add_acl_whitelist/migration.sql b/prisma/schema/migrations/20260926120000_add_acl_whitelist/migration.sql new file mode 100644 index 00000000..25948715 --- /dev/null +++ b/prisma/schema/migrations/20260926120000_add_acl_whitelist/migration.sql @@ -0,0 +1,36 @@ +-- CreateTable +CREATE TABLE "acl_whitelist" ( + "id" TEXT NOT NULL, + "contractAddress" TEXT NOT NULL, + "permittedFunctions" TEXT[], + "addedBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "acl_whitelist_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "acl_events" ( + "id" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "contractAddress" TEXT NOT NULL, + "permittedFunctions" TEXT[], + "actor" TEXT NOT NULL, + "signers" TEXT[], + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "acl_events_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "acl_whitelist_contractAddress_key" ON "acl_whitelist"("contractAddress"); + +-- CreateIndex +CREATE INDEX "acl_whitelist_createdAt_idx" ON "acl_whitelist"("createdAt"); + +-- CreateIndex +CREATE INDEX "acl_events_contractAddress_idx" ON "acl_events"("contractAddress"); + +-- CreateIndex +CREATE INDEX "acl_events_createdAt_idx" ON "acl_events"("createdAt"); diff --git a/src/modules/acl/acl.controllers.ts b/src/modules/acl/acl.controllers.ts new file mode 100644 index 00000000..ff2256c2 --- /dev/null +++ b/src/modules/acl/acl.controllers.ts @@ -0,0 +1,240 @@ +// src/modules/acl/acl.controllers.ts +// HTTP controllers for the ACL whitelist admin API (#966). + +import { Response } from 'express'; +import { + sendSuccess, + sendPaginatedSuccess, + sendNotFound, + sendConflict, + sendForbidden, + sendValidationError, + zodIssuesToDetails, +} from '../../utils/api-response.utils'; +import { AdminRequest } from '../../middlewares/admin-guard.middleware'; +import { logger } from '../../utils/logger.utils'; +import { + AddAclEntryBodySchema, + RemoveAclEntryBodySchema, + RemoveAclEntryParamsSchema, + AclPaginationQuerySchema, +} from './acl.schemas'; +import { + addAclEntry, + removeAclEntry, + listAclWhitelist, + getAclHistory, + AclEntryAlreadyExistsError, + AclEntryNotFoundError, + MultisigVerificationError, +} from './acl.service'; + +/** + * GET /admin/acl + * Paginated list of whitelisted contracts with their permitted function + * sets. + */ +export async function httpListAcl( + req: AdminRequest, + res: Response, + next: (err?: unknown) => void +): Promise { + try { + const parsed = AclPaginationQuerySchema.safeParse(req.query); + if (!parsed.success) { + sendValidationError( + res, + 'Invalid query parameters', + zodIssuesToDetails(parsed.error.issues) + ); + return; + } + const { page, limit } = parsed.data; + + const result = await listAclWhitelist(page, limit); + + sendPaginatedSuccess( + res, + result.items.map(item => ({ + id: item.id, + contractAddress: item.contractAddress, + permittedFunctions: item.permittedFunctions, + addedBy: item.addedBy, + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + })), + { + page: result.page, + limit: result.limit, + totalCount: result.totalCount, + totalPages: result.totalPages, + hasNextPage: result.page < result.totalPages, + hasPrevPage: result.page > 1, + } + ); + } catch (error) { + logger.error({ error }, 'ACL list failed'); + next(error); + } +} + +/** + * POST /admin/acl + * Add a contract address and its permitted function list to the on-chain + * ACL whitelist. Requires admin JWT + 2-of-3 multisig signatures. + */ +export async function httpAddAcl( + req: AdminRequest, + res: Response, + next: (err?: unknown) => void +): Promise { + try { + const parsed = AddAclEntryBodySchema.safeParse(req.body); + if (!parsed.success) { + sendValidationError( + res, + 'Invalid request body', + zodIssuesToDetails(parsed.error.issues) + ); + return; + } + + const entry = await addAclEntry({ + contractAddress: parsed.data.contractAddress, + permittedFunctions: parsed.data.permittedFunctions, + signatures: parsed.data.signatures, + actor: req.adminId || 'unknown', + }); + + sendSuccess( + res, + { + id: entry.id, + contractAddress: entry.contractAddress, + permittedFunctions: entry.permittedFunctions, + addedBy: entry.addedBy, + createdAt: entry.createdAt.toISOString(), + }, + 201 + ); + } catch (error) { + if (error instanceof MultisigVerificationError) { + sendForbidden(res, error.message); + return; + } + if (error instanceof AclEntryAlreadyExistsError) { + sendConflict(res, error.message); + return; + } + logger.error({ error }, 'ACL add failed'); + next(error); + } +} + +/** + * DELETE /admin/acl/:contractId + * Remove a contract from the on-chain ACL whitelist. Requires admin JWT + + * 2-of-3 multisig signatures. + */ +export async function httpRemoveAcl( + req: AdminRequest, + res: Response, + next: (err?: unknown) => void +): Promise { + try { + const paramsParsed = RemoveAclEntryParamsSchema.safeParse(req.params); + if (!paramsParsed.success) { + sendValidationError( + res, + 'Invalid path parameters', + zodIssuesToDetails(paramsParsed.error.issues) + ); + return; + } + + const bodyParsed = RemoveAclEntryBodySchema.safeParse(req.body); + if (!bodyParsed.success) { + sendValidationError( + res, + 'Invalid request body', + zodIssuesToDetails(bodyParsed.error.issues) + ); + return; + } + + const removed = await removeAclEntry({ + contractId: paramsParsed.data.contractId, + signatures: bodyParsed.data.signatures, + actor: req.adminId || 'unknown', + }); + + sendSuccess(res, { + id: removed.id, + contractAddress: removed.contractAddress, + removed: true, + }); + } catch (error) { + if (error instanceof MultisigVerificationError) { + sendForbidden(res, error.message); + return; + } + if (error instanceof AclEntryNotFoundError) { + sendNotFound(res, 'Whitelist entry'); + return; + } + logger.error( + { error, contractId: req.params.contractId }, + 'ACL remove failed' + ); + next(error); + } +} + +/** + * GET /admin/acl/history + * Paginated add/remove event log with actor and timestamp. + */ +export async function httpGetAclHistory( + req: AdminRequest, + res: Response, + next: (err?: unknown) => void +): Promise { + try { + const parsed = AclPaginationQuerySchema.safeParse(req.query); + if (!parsed.success) { + sendValidationError( + res, + 'Invalid query parameters', + zodIssuesToDetails(parsed.error.issues) + ); + return; + } + const { page, limit } = parsed.data; + + const result = await getAclHistory(page, limit); + + sendPaginatedSuccess( + res, + result.items.map(item => ({ + id: item.id, + eventType: item.eventType, + contractAddress: item.contractAddress, + permittedFunctions: item.permittedFunctions, + actor: item.actor, + signers: item.signers, + createdAt: item.createdAt.toISOString(), + })), + { + page: result.page, + limit: result.limit, + totalCount: result.totalCount, + totalPages: result.totalPages, + hasNextPage: result.page < result.totalPages, + hasPrevPage: result.page > 1, + } + ); + } catch (error) { + logger.error({ error }, 'ACL history fetch failed'); + next(error); + } +} diff --git a/src/modules/acl/acl.integration.test.ts b/src/modules/acl/acl.integration.test.ts new file mode 100644 index 00000000..25a8a610 --- /dev/null +++ b/src/modules/acl/acl.integration.test.ts @@ -0,0 +1,313 @@ +import request from 'supertest'; +import express from 'express'; +import jwt from 'jsonwebtoken'; +import { Keypair } from '@stellar/stellar-base'; +import adminRouter from '../admin/admin.routes'; +import { prisma } from '../../utils/prisma.utils'; +import { errorHandler } from '../../middlewares/error.middleware'; +import { buildAclCanonicalMessage } from './acl.service'; + +const app = express(); +app.use(express.json()); +app.use('/admin', adminRouter); +app.use(errorHandler); + +const CONTRACT_ADDRESS = + 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + +function sign( + keypair: Keypair, + action: 'add' | 'remove', + contractAddress: string, + permittedFunctions: string[] +) { + const message = buildAclCanonicalMessage( + action, + contractAddress, + permittedFunctions + ); + return { + wallet: keypair.publicKey(), + signature: keypair.sign(message).toString('base64'), + }; +} + +describe('ACL whitelist management (#966)', () => { + let adminToken: string; + let nonAdminToken: string; + let signer1: Keypair; + let signer2: Keypair; + + beforeAll(() => { + const secret = + process.env.JWT_SECRET || + 'accesslayer_default_development_jwt_secret_key_32_bytes'; + adminToken = jwt.sign( + { sub: 'GAADMINACLTESTWALLET1111111111111111111111111111111', role: 'admin' }, + secret + ); + nonAdminToken = jwt.sign( + { sub: 'GANONADMINACLTESTWALLET222222222222222222222222222', role: 'user' }, + secret + ); + signer1 = Keypair.random(); + signer2 = Keypair.random(); + }); + + beforeEach(() => { + jest.restoreAllMocks(); + delete process.env.ADMIN_MULTISIG_WALLETS; + }); + + describe('authorization', () => { + it('returns 401 with a clear message when no token is supplied', async () => { + // adminGuard convention: missing/invalid token -> 401, valid but + // non-admin token -> 403 (see admin-guard.middleware.ts). + const res = await request(app).get('/admin/acl'); + expect(res.status).toBe(401); + expect(res.body.error.message).toMatch(/authorization/i); + }); + + it('returns 403 for a non-admin JWT on every mutation', async () => { + const addRes = await request(app) + .post('/admin/acl') + .set('Authorization', `Bearer ${nonAdminToken}`) + .send({ + contractAddress: CONTRACT_ADDRESS, + permittedFunctions: ['transfer'], + signatures: [], + }); + expect(addRes.status).toBe(403); + expect(addRes.body.error.code).toBe('FORBIDDEN'); + + const removeRes = await request(app) + .delete('/admin/acl/some-id') + .set('Authorization', `Bearer ${nonAdminToken}`) + .send({ signatures: [] }); + expect(removeRes.status).toBe(403); + expect(removeRes.body.error.code).toBe('FORBIDDEN'); + }); + }); + + describe('GET /admin/acl', () => { + it('returns the paginated list of whitelisted contracts with function sets', async () => { + jest.spyOn(prisma.aclWhitelist, 'findMany').mockResolvedValue([ + { + id: 'acl-1', + contractAddress: CONTRACT_ADDRESS, + permittedFunctions: ['transfer', 'approve'], + addedBy: signer1.publicKey(), + createdAt: new Date(), + updatedAt: new Date(), + } as any, + ]); + jest.spyOn(prisma.aclWhitelist, 'count').mockResolvedValue(1); + + const res = await request(app) + .get('/admin/acl') + .set('Authorization', `Bearer ${adminToken}`); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0]).toMatchObject({ + contractAddress: CONTRACT_ADDRESS, + permittedFunctions: ['transfer', 'approve'], + }); + expect(res.body.meta.totalCount).toBe(1); + }); + }); + + describe('POST /admin/acl', () => { + it('returns 422 for an invalid contract address', async () => { + const res = await request(app) + .post('/admin/acl') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + contractAddress: 'not-a-valid-address', + permittedFunctions: ['transfer'], + signatures: [], + }); + + expect(res.status).toBe(400); + expect(res.body.error.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: 'contractAddress' }), + ]) + ); + }); + + it('returns 422 for an empty function list', async () => { + const res = await request(app) + .post('/admin/acl') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + contractAddress: CONTRACT_ADDRESS, + permittedFunctions: [], + signatures: [], + }); + + expect(res.status).toBe(400); + }); + + it('returns 403 when fewer than 2 valid admin signatures are supplied', async () => { + jest.spyOn(prisma.aclWhitelist, 'findUnique').mockResolvedValue(null); + const sig1 = sign(signer1, 'add', CONTRACT_ADDRESS, ['transfer']); + + const res = await request(app) + .post('/admin/acl') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + contractAddress: CONTRACT_ADDRESS, + permittedFunctions: ['transfer'], + signatures: [sig1], + }); + + expect(res.status).toBe(403); + expect(res.body.error.message).toMatch(/requires 2/i); + }); + + it('adds the contract when 2 valid, distinct admin signatures are supplied', async () => { + jest.spyOn(prisma.aclWhitelist, 'findUnique').mockResolvedValue(null); + const createdEntry = { + id: 'acl-new-1', + contractAddress: CONTRACT_ADDRESS, + permittedFunctions: ['transfer'], + addedBy: 'admin-wallet', + createdAt: new Date(), + updatedAt: new Date(), + }; + jest + .spyOn(prisma, '$transaction') + .mockResolvedValue([createdEntry, {}]); + jest.spyOn(prisma.auditLog, 'create').mockResolvedValue({} as any); + + const sig1 = sign(signer1, 'add', CONTRACT_ADDRESS, ['transfer']); + const sig2 = sign(signer2, 'add', CONTRACT_ADDRESS, ['transfer']); + + const res = await request(app) + .post('/admin/acl') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + contractAddress: CONTRACT_ADDRESS, + permittedFunctions: ['transfer'], + signatures: [sig1, sig2], + }); + + expect(res.status).toBe(201); + expect(res.body.data.contractAddress).toBe(CONTRACT_ADDRESS); + }); + + it('returns 409 when the contract is already whitelisted', async () => { + jest.spyOn(prisma.aclWhitelist, 'findUnique').mockResolvedValue({ + id: 'acl-existing', + contractAddress: CONTRACT_ADDRESS, + permittedFunctions: ['transfer'], + addedBy: 'someone', + createdAt: new Date(), + updatedAt: new Date(), + } as any); + + const sig1 = sign(signer1, 'add', CONTRACT_ADDRESS, ['transfer']); + const sig2 = sign(signer2, 'add', CONTRACT_ADDRESS, ['transfer']); + + const res = await request(app) + .post('/admin/acl') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + contractAddress: CONTRACT_ADDRESS, + permittedFunctions: ['transfer'], + signatures: [sig1, sig2], + }); + + expect(res.status).toBe(409); + }); + }); + + describe('DELETE /admin/acl/:contractId', () => { + it('returns 403 when fewer than 2 valid admin signatures are supplied', async () => { + jest.spyOn(prisma.aclWhitelist, 'findFirst').mockResolvedValue({ + id: 'acl-1', + contractAddress: CONTRACT_ADDRESS, + permittedFunctions: ['transfer'], + addedBy: 'someone', + createdAt: new Date(), + updatedAt: new Date(), + } as any); + + const sig1 = sign(signer1, 'remove', CONTRACT_ADDRESS, ['transfer']); + + const res = await request(app) + .delete('/admin/acl/acl-1') + .set('Authorization', `Bearer ${adminToken}`) + .send({ signatures: [sig1] }); + + expect(res.status).toBe(403); + }); + + it('removes the entry when 2 valid, distinct admin signatures are supplied', async () => { + jest.spyOn(prisma.aclWhitelist, 'findFirst').mockResolvedValue({ + id: 'acl-1', + contractAddress: CONTRACT_ADDRESS, + permittedFunctions: ['transfer'], + addedBy: 'someone', + createdAt: new Date(), + updatedAt: new Date(), + } as any); + jest.spyOn(prisma, '$transaction').mockResolvedValue([{}, {}]); + jest.spyOn(prisma.auditLog, 'create').mockResolvedValue({} as any); + + const sig1 = sign(signer1, 'remove', CONTRACT_ADDRESS, ['transfer']); + const sig2 = sign(signer2, 'remove', CONTRACT_ADDRESS, ['transfer']); + + const res = await request(app) + .delete('/admin/acl/acl-1') + .set('Authorization', `Bearer ${adminToken}`) + .send({ signatures: [sig1, sig2] }); + + expect(res.status).toBe(200); + expect(res.body.data.removed).toBe(true); + }); + + it('returns 404 for an unknown contractId', async () => { + jest.spyOn(prisma.aclWhitelist, 'findFirst').mockResolvedValue(null); + + const sig1 = sign(signer1, 'remove', CONTRACT_ADDRESS, ['transfer']); + const sig2 = sign(signer2, 'remove', CONTRACT_ADDRESS, ['transfer']); + + const res = await request(app) + .delete('/admin/acl/does-not-exist') + .set('Authorization', `Bearer ${adminToken}`) + .send({ signatures: [sig1, sig2] }); + + expect(res.status).toBe(404); + }); + }); + + describe('GET /admin/acl/history', () => { + it('returns events with actor and timestamp', async () => { + jest.spyOn(prisma.aclEvent, 'findMany').mockResolvedValue([ + { + id: 'evt-1', + eventType: 'added', + contractAddress: CONTRACT_ADDRESS, + permittedFunctions: ['transfer'], + actor: 'admin-wallet', + signers: [signer1.publicKey(), signer2.publicKey()], + createdAt: new Date(), + } as any, + ]); + jest.spyOn(prisma.aclEvent, 'count').mockResolvedValue(1); + + const res = await request(app) + .get('/admin/acl/history') + .set('Authorization', `Bearer ${adminToken}`); + + expect(res.status).toBe(200); + expect(res.body.data[0]).toMatchObject({ + eventType: 'added', + actor: 'admin-wallet', + }); + expect(res.body.data[0].createdAt).toEqual(expect.any(String)); + }); + }); +}); diff --git a/src/modules/acl/acl.routes.ts b/src/modules/acl/acl.routes.ts new file mode 100644 index 00000000..cc67a7e2 --- /dev/null +++ b/src/modules/acl/acl.routes.ts @@ -0,0 +1,29 @@ +// src/modules/acl/acl.routes.ts +// Admin routes for on-chain ACL whitelist management (#966). Mounted at +// /admin/acl by admin.routes.ts. Every route requires a valid admin JWT +// (adminGuard). + +import { Router } from 'express'; +import { adminGuard } from '../../middlewares/admin-guard.middleware'; +import { + httpListAcl, + httpAddAcl, + httpRemoveAcl, + httpGetAclHistory, +} from './acl.controllers'; + +const aclRouter = Router(); + +/** GET /admin/acl/history - add/remove event log with actor and timestamp. */ +aclRouter.get('/history', adminGuard, httpGetAclHistory); + +/** GET /admin/acl - paginated list of whitelisted contracts. */ +aclRouter.get('/', adminGuard, httpListAcl); + +/** POST /admin/acl - add a contract + permitted functions to the ACL. */ +aclRouter.post('/', adminGuard, httpAddAcl); + +/** DELETE /admin/acl/:contractId - remove a contract from the ACL. */ +aclRouter.delete('/:contractId', adminGuard, httpRemoveAcl); + +export default aclRouter; diff --git a/src/modules/acl/acl.schemas.ts b/src/modules/acl/acl.schemas.ts new file mode 100644 index 00000000..2167748d --- /dev/null +++ b/src/modules/acl/acl.schemas.ts @@ -0,0 +1,85 @@ +// src/modules/acl/acl.schemas.ts +// Validation schemas for the ACL whitelist admin API (#966). + +import { z } from 'zod'; + +/** + * A Soroban contract address (StrKey "contract" flavor): starts with 'C', + * exactly 56 characters, Base32 (A-Z, 2-7). This is distinct from an + * account address (which starts with 'G') - the ACL whitelists external + * *contracts*, not wallets. + */ +export const ContractAddressSchema = z + .string() + .length(56, 'Contract address must be exactly 56 characters long') + .regex( + /^C[A-Z2-7]{55}$/, + "Invalid contract address format (must start with 'C' and use Base32 characters)" + ); + +/** Max distinct permitted functions accepted per contract. */ +export const ACL_MAX_FUNCTIONS = 50; + +/** + * A contract function name: alphanumeric plus underscore, matching Soroban + * contract function naming rules. + */ +export const FunctionNameSchema = z + .string() + .min(1, 'Function name is required') + .max(64, 'Function name must be at most 64 characters') + .regex( + /^[a-zA-Z_][a-zA-Z0-9_]*$/, + 'Function name must be alphanumeric/underscore and cannot start with a digit' + ); + +/** A single admin's signature over the ACL action's canonical message. */ +export const AclSignatureSchema = z.object({ + wallet: z + .string() + .length(56, 'Signer wallet must be exactly 56 characters long') + .regex(/^G[A-Z2-7]{55}$/, 'Invalid signer wallet address format'), + signature: z.string().min(1, 'Signature is required'), +}); + +export type AclSignatureInput = z.infer; + +/** Body for POST /admin/acl. */ +export const AddAclEntryBodySchema = z.object({ + contractAddress: ContractAddressSchema, + permittedFunctions: z + .array(FunctionNameSchema) + .min(1, 'At least one permitted function is required') + .max( + ACL_MAX_FUNCTIONS, + `At most ${ACL_MAX_FUNCTIONS} functions can be whitelisted per contract` + ) + .transform(fns => Array.from(new Set(fns))), + signatures: z + .array(AclSignatureSchema) + .min(1, 'At least one admin signature is required'), +}); + +export type AddAclEntryBody = z.infer; + +/** Params for DELETE /admin/acl/:contractId. */ +export const RemoveAclEntryParamsSchema = z.object({ + contractId: z.string().min(1, 'contractId is required'), +}); + +/** Body for DELETE /admin/acl/:contractId. */ +export const RemoveAclEntryBodySchema = z.object({ + signatures: z + .array(AclSignatureSchema) + .min(1, 'At least one admin signature is required'), +}); + +export type RemoveAclEntryBody = z.infer; + +/** Shared page/limit query schema for the list and history endpoints. */ +export const AclPaginationQuerySchema = z.object({ + page: z.coerce.number().int().positive().optional().default(1), + limit: z.coerce.number().int().positive().max(100).optional().default(20), +}); + +export type AclPaginationQuery = z.infer; diff --git a/src/modules/acl/acl.service.ts b/src/modules/acl/acl.service.ts new file mode 100644 index 00000000..e70c69c8 --- /dev/null +++ b/src/modules/acl/acl.service.ts @@ -0,0 +1,358 @@ +// src/modules/acl/acl.service.ts +// On-chain ACL whitelist management (#966). +// +// Controls which external contracts are permitted to interact with the +// platform, and which functions each is permitted to call. Every mutation +// (add or remove) requires a 2-of-3 admin multisig - Ed25519 signatures +// from distinct configured admin wallets over a canonical message binding +// the signature to the exact action - following the same pattern used for +// key deprecation (see keys/key-deprecation.service.ts). + +import { createHash } from 'crypto'; +import { Keypair } from '@stellar/stellar-base'; +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; +import { createAuditEntry } from '../admin/audit-log.service'; +import { + parseMultisigAdminWallets, + MultisigVerificationError, +} from '../keys/key-deprecation.service'; + +export { MultisigVerificationError }; + +/** Minimum number of distinct admin signatures required for an ACL mutation. */ +export const ACL_MULTISIG_THRESHOLD = 2; +/** The admin quorum size the threshold is expressed against (2 of 3). */ +export const ACL_MULTISIG_SET_SIZE = 3; + +export interface AclSignature { + wallet: string; + signature: string; +} + +export class AclEntryAlreadyExistsError extends Error { + constructor(contractAddress: string) { + super(`Contract is already whitelisted: ${contractAddress}`); + this.name = 'AclEntryAlreadyExistsError'; + } +} + +export class AclEntryNotFoundError extends Error { + constructor(contractId: string) { + super(`Whitelist entry not found: ${contractId}`); + this.name = 'AclEntryNotFoundError'; + } +} + +const STELLAR_ADDRESS_PATTERN = /^G[A-Z2-7]{55}$/; + +/** + * Canonical message each admin signs for an ACL mutation: + * SHA256("acl:::") + * Binds the signature to the exact action, contract, and function set so a + * signature can't be replayed against a different contract or function list. + */ +export function buildAclCanonicalMessage( + action: 'add' | 'remove', + contractAddress: string, + permittedFunctions: string[] +): Buffer { + const sortedFunctions = [...permittedFunctions].sort().join(','); + const payload = `acl:${action}:${contractAddress}:${sortedFunctions}`; + return createHash('sha256').update(payload, 'utf8').digest(); +} + +/** + * Verify submitted ACL signatures against the canonical message for the + * given action. Enforces the 2-of-3 multisig threshold: at least two + * distinct valid signatures, each from a wallet in the configured admin + * quorum when ADMIN_MULTISIG_WALLETS is set. + * + * @throws {MultisigVerificationError} on invalid, duplicate-threshold, or + * non-admin signers. + */ +export function verifyAclSignatures(params: { + action: 'add' | 'remove'; + contractAddress: string; + permittedFunctions: string[]; + signatures: AclSignature[]; +}): { validWallets: string[] } { + const message = buildAclCanonicalMessage( + params.action, + params.contractAddress, + params.permittedFunctions + ); + const adminWallets = parseMultisigAdminWallets(); + const adminSet = new Set(adminWallets.map(wallet => wallet.toLowerCase())); + + const seen = new Set(); + const validWallets: string[] = []; + + for (const { wallet, signature } of params.signatures) { + const normalized = wallet.trim(); + const lower = normalized.toLowerCase(); + if (seen.has(lower)) { + continue; // duplicate wallet signatures count once + } + + if (!STELLAR_ADDRESS_PATTERN.test(normalized)) { + throw new MultisigVerificationError( + `Invalid admin wallet address: ${normalized}` + ); + } + if (adminSet.size > 0 && !adminSet.has(lower)) { + throw new MultisigVerificationError( + `Signer ${normalized} is not a configured admin` + ); + } + + let verified = false; + try { + const signatureBuffer = Buffer.from(signature, 'base64'); + if (signatureBuffer.length === 64) { + verified = Keypair.fromPublicKey(normalized).verify( + message, + signatureBuffer + ); + } + } catch { + verified = false; + } + if (!verified) { + throw new MultisigVerificationError( + `Invalid admin signature from ${normalized}` + ); + } + + seen.add(lower); + validWallets.push(normalized); + } + + if (adminSet.size > 0 && adminSet.size !== ACL_MULTISIG_SET_SIZE) { + throw new MultisigVerificationError( + `ADMIN_MULTISIG_WALLETS must contain exactly ${ACL_MULTISIG_SET_SIZE} wallets (got ${adminSet.size})` + ); + } + if (validWallets.length < ACL_MULTISIG_THRESHOLD) { + throw new MultisigVerificationError( + `ACL mutations require ${ACL_MULTISIG_THRESHOLD} of ${adminSet.size || ACL_MULTISIG_SET_SIZE} admin signatures; got ${validWallets.length}` + ); + } + + return { validWallets }; +} + +export interface PageResult { + items: T[]; + page: number; + limit: number; + totalCount: number; + totalPages: number; +} + +/** + * Paginated list of whitelisted contracts with their permitted function + * sets, newest first. + */ +export async function listAclWhitelist( + page: number, + limit: number +): Promise> { + const [items, totalCount] = await Promise.all([ + prisma.aclWhitelist.findMany({ + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + prisma.aclWhitelist.count(), + ]); + + return { + items, + page, + limit, + totalCount, + totalPages: Math.max(1, Math.ceil(totalCount / limit)), + }; +} + +export interface AddAclEntryInput { + contractAddress: string; + permittedFunctions: string[]; + signatures: AclSignature[]; + actor: string; +} + +/** + * Add a contract to the on-chain ACL whitelist with its permitted + * functions. Verifies the 2-of-3 admin multisig, rejects duplicates, and + * records both the whitelist row and an audit event in one transaction. + * + * TODO: submit the whitelist_contract contract call via the Stellar SDK; + * on-chain failure should return 502 before this point. The DB row is the + * off-chain read model, kept in sync with on-chain state by the indexer. + */ +export async function addAclEntry(input: AddAclEntryInput) { + const { validWallets } = verifyAclSignatures({ + action: 'add', + contractAddress: input.contractAddress, + permittedFunctions: input.permittedFunctions, + signatures: input.signatures, + }); + + const existing = await prisma.aclWhitelist.findUnique({ + where: { contractAddress: input.contractAddress }, + }); + if (existing) { + throw new AclEntryAlreadyExistsError(input.contractAddress); + } + + logger.info( + { + operation: 'whitelist_contract', + contractAddress: input.contractAddress, + permittedFunctions: input.permittedFunctions, + }, + 'Submitting whitelist_contract contract call' + ); + + const metadata = { + contractAddress: input.contractAddress, + permittedFunctions: input.permittedFunctions, + signers: validWallets, + }; + + const [entry] = await prisma.$transaction([ + prisma.aclWhitelist.create({ + data: { + contractAddress: input.contractAddress, + permittedFunctions: input.permittedFunctions, + addedBy: input.actor, + }, + }), + prisma.aclEvent.create({ + data: { + eventType: 'added', + contractAddress: input.contractAddress, + permittedFunctions: input.permittedFunctions, + actor: input.actor, + signers: validWallets, + }, + }), + ]); + + await createAuditEntry({ + actorWallet: input.actor, + actionType: 'acl_contract_added', + targetId: entry.id, + payload: metadata, + }); + + return entry; +} + +export interface RemoveAclEntryInput { + contractId: string; + signatures: AclSignature[]; + actor: string; +} + +/** + * Remove a contract from the on-chain ACL whitelist. `contractId` may be + * either the whitelist row id or the contract address itself. Verifies the + * 2-of-3 admin multisig against the entry's current function set before + * removing it, and records the removal in the audit event log. + * + * TODO: submit the revoke_contract contract call via the Stellar SDK; + * on-chain failure should return 502 before this point. + */ +export async function removeAclEntry(input: RemoveAclEntryInput) { + const existing = await prisma.aclWhitelist.findFirst({ + where: { + OR: [{ id: input.contractId }, { contractAddress: input.contractId }], + }, + }); + if (!existing) { + throw new AclEntryNotFoundError(input.contractId); + } + + const { validWallets } = verifyAclSignatures({ + action: 'remove', + contractAddress: existing.contractAddress, + permittedFunctions: existing.permittedFunctions, + signatures: input.signatures, + }); + + logger.info( + { + operation: 'revoke_contract', + contractAddress: existing.contractAddress, + }, + 'Submitting revoke_contract contract call' + ); + + const metadata = { + contractAddress: existing.contractAddress, + permittedFunctions: existing.permittedFunctions, + signers: validWallets, + }; + + await prisma.$transaction([ + prisma.aclWhitelist.delete({ where: { id: existing.id } }), + prisma.aclEvent.create({ + data: { + eventType: 'removed', + contractAddress: existing.contractAddress, + permittedFunctions: existing.permittedFunctions, + actor: input.actor, + signers: validWallets, + }, + }), + ]); + + await createAuditEntry({ + actorWallet: input.actor, + actionType: 'acl_contract_removed', + targetId: existing.id, + payload: metadata, + }); + + return existing; +} + +/** Paginated add/remove event log, newest first. */ +export async function getAclHistory(page: number, limit: number): Promise< + PageResult<{ + id: string; + eventType: string; + contractAddress: string; + permittedFunctions: string[]; + actor: string; + signers: string[]; + createdAt: Date; + }> +> { + const [items, totalCount] = await Promise.all([ + prisma.aclEvent.findMany({ + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + prisma.aclEvent.count(), + ]); + + return { + items, + page, + limit, + totalCount, + totalPages: Math.max(1, Math.ceil(totalCount / limit)), + }; +} diff --git a/src/modules/admin/admin.routes.ts b/src/modules/admin/admin.routes.ts index c3257fb1..eaf45ec4 100644 --- a/src/modules/admin/admin.routes.ts +++ b/src/modules/admin/admin.routes.ts @@ -8,6 +8,7 @@ import { httpGetAuditLog, } from './admin.controllers'; import { httpSyncKeyState } from './key-sync.controllers'; +import aclRouter from '../acl/acl.routes'; import { getKeySnapshot, KeySnapshotNotFoundError } from './key-snapshot.service'; import { createAuditEntry } from './audit-log.service'; import { invalidateProtocolStatusCache } from '../protocol/protocol.routes'; @@ -116,6 +117,10 @@ adminRouter.post('/keys/:keyId/sync', adminGuard, httpSyncKeyState); adminRouter.patch('/protocol-fee', adminGuard, httpUpdateProtocolFee); adminRouter.get('/audit-log', adminGuard, httpGetAuditLog); +// ── ACL whitelist management (#966) ─────────────────────────── +// GET/POST /admin/acl, DELETE /admin/acl/:contractId, GET /admin/acl/history +adminRouter.use('/acl', aclRouter); + /** * GET /api/v1/admin/analytics?from=&to= *