Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions prisma/schema/acl.prisma
Original file line number Diff line number Diff line change
@@ -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")
}
Original file line number Diff line number Diff line change
@@ -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");
240 changes: 240 additions & 0 deletions src/modules/acl/acl.controllers.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
Loading
Loading