From f87fb583f9166a6bad9b230a37fcc35663ab2fda Mon Sep 17 00:00:00 2001 From: DataByNature Date: Tue, 28 Jul 2026 08:43:27 +0100 Subject: [PATCH] feat: add family & team sub-accounts with scoped permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SubAccount model with granular permissions (VIEW, DEPOSIT, WITHDRAW, MANAGE_STRATEGY) allowing a parent user to delegate limited access to a child user's account. Permissions are independent flags, not hierarchical. New middleware requireSubAccountPermission(permission) enforces delegation on deposit/withdraw routes without altering the existing self-access path. Delegated actions are attributed via actingAsUserId on Transaction and AgentLog for a complete audit trail visible to both parent and child. CRUD endpoints: - POST /api/v1/sub-accounts — create relationship with initial permissions - PATCH /api/v1/sub-accounts/:id/permissions — adjust granted permissions - DELETE /api/v1/sub-accounts/:id — soft-revoke (preserves audit trail) Security guarantees: - Self-access passes through untouched (no DB lookup) - Revocation takes effect on the very next request (no caching) - No chained sub-accounts (child cannot be a parent) - Denial paths return 403: missing permission, revoked, non-existent Closes #286 --- docs/openapi.yaml | 195 +++++++++- .../migration.sql | 48 +++ prisma/schema.prisma | 89 +++-- src/controllers/transaction-controller.ts | 18 +- src/index.ts | 2 + src/middleware/index.ts | 1 + src/middleware/subAccount.ts | 71 ++++ src/routes/deposit.ts | 2 + src/routes/sub-accounts.ts | 186 +++++++++ src/routes/withdraw.ts | 2 + src/types/express.d.ts | 1 + tests/unit/middleware/subAccount.test.ts | 342 +++++++++++++++++ tests/unit/routes/sub-accounts.test.ts | 355 ++++++++++++++++++ 13 files changed, 1282 insertions(+), 30 deletions(-) create mode 100644 prisma/migrations/20260728000000_add_sub_accounts/migration.sql create mode 100644 src/middleware/subAccount.ts create mode 100644 src/routes/sub-accounts.ts create mode 100644 tests/unit/middleware/subAccount.test.ts create mode 100644 tests/unit/routes/sub-accounts.test.ts diff --git a/docs/openapi.yaml b/docs/openapi.yaml index b137ffd..4e93347 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -58,6 +58,8 @@ tags: description: Goal-based investing — a target amount + date driving the agent's strategy selection - name: admin description: Admin-only management endpoints + - name: sub-accounts + description: Family & team sub-accounts with scoped, revocable permissions - name: metrics description: Prometheus-compatible metrics endpoint @@ -1086,7 +1088,9 @@ paths: summary: Initiate an on-chain deposit description: | Submits a deposit transaction to the active protocol on behalf of the - authenticated user. The user must match the userId in the request body. + authenticated user. The user must match the userId in the request body, + OR the caller must hold an active sub-account with DEPOSIT permission + for the target user. security: - BearerAuth: [] requestBody: @@ -1314,7 +1318,9 @@ paths: summary: Initiate an on-chain withdrawal description: | Submits a withdrawal transaction from the active protocol on behalf of - the authenticated user. The user must match the userId in the request body. + the authenticated user. The user must match the userId in the request body, + OR the caller must hold an active sub-account with WITHDRAW permission + for the target user. security: - BearerAuth: [] requestBody: @@ -2859,6 +2865,147 @@ paths: '404': $ref: '#/components/responses/NotFound' + # ── Sub-Accounts ─────────────────────────────────────────────────────────── + + /api/v1/sub-accounts: + post: + tags: [sub-accounts] + operationId: createSubAccount + summary: Create a sub-account relationship + description: | + Creates a parent→child sub-account link with an initial permission set. + The authenticated user becomes the parent. The child must exist and must + not already be a parent (no chained sub-accounts). + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [childUserId, permissions] + properties: + childUserId: + type: string + format: uuid + description: The child user's ID + permissions: + type: array + items: + $ref: '#/components/schemas/SubAccountPermission' + minItems: 1 + maxItems: 4 + description: Initial permission set + example: + childUserId: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + permissions: [VIEW, DEPOSIT] + responses: + '201': + description: Sub-account created + content: + application/json: + schema: + type: object + properties: + subAccount: + $ref: '#/components/schemas/SubAccount' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Child user not found + '409': + description: Sub-account relationship already exists + + /api/v1/sub-accounts/{id}/permissions: + patch: + tags: [sub-accounts] + operationId: updateSubAccountPermissions + summary: Update sub-account permissions + description: | + Replaces the permission set for an existing sub-account. Only the + parent who owns the sub-account can update permissions. + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + description: Sub-account ID + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [permissions] + properties: + permissions: + type: array + items: + $ref: '#/components/schemas/SubAccountPermission' + minItems: 1 + maxItems: 4 + example: + permissions: [VIEW, DEPOSIT, WITHDRAW] + responses: + '200': + description: Permissions updated + content: + application/json: + schema: + type: object + properties: + subAccount: + $ref: '#/components/schemas/SubAccount' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + + /api/v1/sub-accounts/{id}: + delete: + tags: [sub-accounts] + operationId: revokeSubAccount + summary: Revoke a sub-account + description: | + Soft-revokes a sub-account by setting its status to REVOKED. Only the + parent who owns the sub-account can revoke it. Revocation takes effect + immediately on the next request. The audit trail is preserved. + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + description: Sub-account ID + responses: + '200': + description: Sub-account revoked + content: + application/json: + schema: + type: object + properties: + subAccount: + $ref: '#/components/schemas/SubAccount' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + components: securitySchemes: BearerAuth: @@ -3230,6 +3377,13 @@ components: protocolName: type: string nullable: true + actingAsUserId: + type: string + format: uuid + nullable: true + description: > + Non-null when the action was performed by a parent on behalf + of a child via a sub-account delegation. # ── Recurring Deposit ────────────────────────────────────────────────── RecurringDepositPlan: @@ -3345,6 +3499,43 @@ components: items: {} - type: object + # ── Sub-Accounts ───────────────────────────────────────────────────────── + SubAccountPermission: + type: string + enum: [VIEW, DEPOSIT, WITHDRAW, MANAGE_STRATEGY] + description: > + Granular permission for sub-account delegation. Permissions are + independent flags — having DEPOSIT does not implicitly grant VIEW. + SubAccountStatus: + type: string + enum: [ACTIVE, REVOKED] + description: ACTIVE means the sub-account is live; REVOKED means it was soft-deleted. + SubAccount: + type: object + properties: + id: + type: string + format: uuid + parentUserId: + type: string + format: uuid + childUserId: + type: string + format: uuid + permissions: + type: array + items: + $ref: '#/components/schemas/SubAccountPermission' + status: + $ref: '#/components/schemas/SubAccountStatus' + createdAt: + type: string + format: date-time + revokedAt: + type: string + format: date-time + nullable: true + # ── Fiat ────────────────────────────────────────────────────────────── FiatDirection: type: string diff --git a/prisma/migrations/20260728000000_add_sub_accounts/migration.sql b/prisma/migrations/20260728000000_add_sub_accounts/migration.sql new file mode 100644 index 0000000..81373c9 --- /dev/null +++ b/prisma/migrations/20260728000000_add_sub_accounts/migration.sql @@ -0,0 +1,48 @@ +-- CreateEnum +CREATE TYPE "SubAccountPermission" AS ENUM ('VIEW', 'DEPOSIT', 'WITHDRAW', 'MANAGE_STRATEGY'); + +-- CreateEnum +CREATE TYPE "SubAccountStatus" AS ENUM ('ACTIVE', 'REVOKED'); + +-- AlterTable: Add actingAsUserId to Transaction +ALTER TABLE "transactions" ADD COLUMN "actingAsUserId" TEXT; + +-- AlterTable: Add actingAsUserId to AgentLog +ALTER TABLE "agent_logs" ADD COLUMN "actingAsUserId" TEXT; + +-- CreateTable +CREATE TABLE "sub_accounts" ( + "id" TEXT NOT NULL, + "parentUserId" TEXT NOT NULL, + "childUserId" TEXT NOT NULL, + "permissions" "SubAccountPermission"[] NOT NULL, + "status" "SubAccountStatus" NOT NULL DEFAULT 'ACTIVE', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revokedAt" TIMESTAMP(3), + + CONSTRAINT "sub_accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "sub_accounts_parentUserId_childUserId_key" ON "sub_accounts"("parentUserId", "childUserId"); + +-- CreateIndex +CREATE INDEX "sub_accounts_parentUserId_idx" ON "sub_accounts"("parentUserId"); + +-- CreateIndex +CREATE INDEX "sub_accounts_childUserId_idx" ON "sub_accounts"("childUserId"); + +-- CreateIndex +CREATE INDEX "sub_accounts_status_idx" ON "sub_accounts"("status"); + +-- CreateIndex +CREATE INDEX "transactions_actingAsUserId_idx" ON "transactions"("actingAsUserId"); + +-- CreateIndex +CREATE INDEX "agent_logs_actingAsUserId_idx" ON "agent_logs"("actingAsUserId"); + +-- AddForeignKey +ALTER TABLE "sub_accounts" ADD CONSTRAINT "sub_accounts_parentUserId_fkey" FOREIGN KEY ("parentUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sub_accounts" ADD CONSTRAINT "sub_accounts_childUserId_fkey" FOREIGN KEY ("childUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a4ce55f..f6eed1c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -135,6 +135,18 @@ enum DeliveryChannel { BOTH } +enum SubAccountPermission { + VIEW + DEPOSIT + WITHDRAW + MANAGE_STRATEGY +} + +enum SubAccountStatus { + ACTIVE + REVOKED +} + model User { id String @id @default(uuid()) walletAddress String @unique @@ -167,6 +179,8 @@ model User { costBasisLots CostBasisLot[] lotDisposals LotDisposal[] savingsGoals SavingsGoal[] + parentSubAccounts SubAccount[] @relation("ParentOf") + childSubAccounts SubAccount[] @relation("ChildOf") @@map("users") } @@ -271,21 +285,22 @@ model Position { } model Transaction { - id String @id @default(uuid()) - userId String - positionId String? - txHash String? @unique - type TransactionType - status TransactionStatus @default(PENDING) - assetSymbol String - amount Decimal @db.Decimal(36, 18) - fee Decimal? @db.Decimal(36, 18) - network Network - protocolName String? - memo String? - confirmedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + userId String + actingAsUserId String? // non-null when action was performed by a parent on behalf of a child + positionId String? + txHash String? @unique + type TransactionType + status TransactionStatus @default(PENDING) + assetSymbol String + amount Decimal @db.Decimal(36, 18) + fee Decimal? @db.Decimal(36, 18) + network Network + protocolName String? + memo String? + confirmedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) position Position? @relation(fields: [positionId], references: [id]) @@ -299,6 +314,7 @@ model Transaction { @@index([status]) @@index([createdAt]) @@index([userId, createdAt]) + @@index([actingAsUserId]) @@map("transactions") } @@ -371,17 +387,18 @@ model ProtocolRiskScore { } model AgentLog { - id String @id @default(uuid()) - userId String? // null for system-level actions (scans, alerts) - positionId String? // set when log is tied to a specific position - action AgentAction - status AgentStatus - reasoning String? @db.Text - inputData Json? - outputData Json? - errorMessage String? - durationMs Int? - createdAt DateTime @default(now()) + id String @id @default(uuid()) + userId String? // null for system-level actions (scans, alerts) + actingAsUserId String? // non-null when action was performed by a parent on behalf of a child + positionId String? // set when log is tied to a specific position + action AgentAction + status AgentStatus + reasoning String? @db.Text + inputData Json? + outputData Json? + errorMessage String? + durationMs Int? + createdAt DateTime @default(now()) user User? @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -392,6 +409,7 @@ model AgentLog { @@index([userId, status]) @@index([createdAt]) @@index([userId, createdAt]) + @@index([actingAsUserId]) @@map("agent_logs") } @@ -542,6 +560,25 @@ model AlertRule { @@map("alert_rules") } +model SubAccount { + id String @id @default(uuid()) + parentUserId String + childUserId String + permissions SubAccountPermission[] + status SubAccountStatus @default(ACTIVE) + createdAt DateTime @default(now()) + revokedAt DateTime? + + parent User @relation("ParentOf", fields: [parentUserId], references: [id], onDelete: Cascade) + child User @relation("ChildOf", fields: [childUserId], references: [id], onDelete: Cascade) + + @@unique([parentUserId, childUserId]) + @@index([parentUserId]) + @@index([childUserId]) + @@index([status]) + @@map("sub_accounts") +} + /// Fiat on-ramp / off-ramp order (#290). /// Tracks the off-chain payment leg of a fiat<->crypto conversion handled by a /// third-party provider (e.g. MoonPay). The on-chain settlement is reconciled diff --git a/src/controllers/transaction-controller.ts b/src/controllers/transaction-controller.ts index 563df7e..a313fc0 100644 --- a/src/controllers/transaction-controller.ts +++ b/src/controllers/transaction-controller.ts @@ -13,6 +13,7 @@ export interface ExecuteDepositParams { amount: number assetSymbol: string memo?: string + actingAsUserId?: string | null } export interface ExecuteDepositResult { @@ -28,7 +29,8 @@ export interface ExecuteDepositResult { export async function executeDeposit( params: ExecuteDepositParams ): Promise { - const { userId, walletAddress, amount, assetSymbol, memo } = params + const { userId, walletAddress, amount, assetSymbol, memo, actingAsUserId } = + params const user = await db.user.findUnique({ where: { id: userId }, @@ -72,6 +74,7 @@ export async function executeDeposit( const transaction = await db.transaction.create({ data: { userId, + actingAsUserId: actingAsUserId ?? null, txHash: onChainResult.hash, type: 'DEPOSIT', status: transactionStatus, @@ -104,10 +107,19 @@ export async function processOnChainTransaction( ) { const { userId, amount, assetSymbol, protocolName, memo } = req.body - if (!req.auth || req.auth.userId !== userId) { + if (!req.auth) { return sendUnauthorized(res) } + // Allow if acting on own behalf OR if this is a verified sub-account delegation + const isSelf = req.auth.userId === userId + const isDelegated = req.auth.actingAsUserId !== undefined + if (!isSelf && !isDelegated) { + return sendUnauthorized(res) + } + + const actingAsUserId = req.auth.actingAsUserId ?? null + if (type === 'WITHDRAWAL') { const user = await db.user.findUnique({ where: { id: userId }, @@ -155,6 +167,7 @@ export async function processOnChainTransaction( const transaction = await db.transaction.create({ data: { userId, + actingAsUserId, txHash: onChainTransaction.hash, type, status: transactionStatus, @@ -204,6 +217,7 @@ export async function processOnChainTransaction( amount, assetSymbol, memo, + actingAsUserId, }) return res.status(201).json({ diff --git a/src/index.ts b/src/index.ts index 58e74ea..bad92ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,6 +71,7 @@ import fiatRouter from './routes/fiat' import referralsRouter from './routes/referrals' import recurringDepositRouter from './routes/recurring-deposits' import alertsRouter from './routes/alerts' +import subAccountsRouter from './routes/sub-accounts' import { corsMiddleware, jsonBodyParser, @@ -280,6 +281,7 @@ const apiRoutes: ApiRoute[] = [ { path: 'referrals', handlers: [referralsRouter] }, { path: 'deposit/recurring', handlers: [recurringDepositRouter] }, { path: 'alerts', handlers: [alertsRouter] }, + { path: 'sub-accounts', handlers: [subAccountsRouter] }, { path: 'admin', handlers: [adminRateLimiter, adminRouter] }, ] diff --git a/src/middleware/index.ts b/src/middleware/index.ts index a1b45d7..3849955 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -11,4 +11,5 @@ export { permissionsPolicy, } from './security' export { requireAuth, enforceUserAccess, AuthMiddleware } from './authenticate' +export { requireSubAccountPermission } from './subAccount' export type {} from './authenticate' // re-export augmented Request types diff --git a/src/middleware/subAccount.ts b/src/middleware/subAccount.ts new file mode 100644 index 0000000..0cfa44c --- /dev/null +++ b/src/middleware/subAccount.ts @@ -0,0 +1,71 @@ +import { NextFunction, Request, Response } from 'express' +import { SubAccountPermission } from '@prisma/client' +import db from '../db' +import { logger } from '../utils/logger' + +// ─── Middleware ─────────────────────────────────────────────────────────────── + +/** + * Factory that returns middleware enforcing a specific sub-account permission. + * + * When the authenticated user (req.auth.userId) differs from the target user + * (req.params.userId or req.body.userId), this middleware looks up an ACTIVE + * SubAccount row linking them and checks that the required permission is + * present. On success it sets req.auth.actingAsUserId to the parent's id so + * downstream controllers can attribute the action. + * + * Self-access (parentUserId === childUserId) passes through without a DB + * lookup, preserving the existing self-access path completely. + * + * Must be placed AFTER requireAuth in the middleware chain. + */ +export function requireSubAccountPermission(permission: SubAccountPermission) { + return async (req: Request, res: Response, next: NextFunction) => { + if (!req.auth) { + res.status(401).json({ error: 'Unauthorized' }) + return + } + + const targetUserId = req.params.userId ?? req.body?.userId + + // Self-access: no sub-account check needed. + if (!targetUserId || req.auth.userId === targetUserId) { + return next() + } + + try { + const subAccount = await db.subAccount.findUnique({ + where: { + parentUserId_childUserId: { + parentUserId: req.auth.userId, + childUserId: targetUserId, + }, + }, + }) + + if (!subAccount || subAccount.status !== 'ACTIVE') { + res.status(403).json({ error: 'Forbidden' }) + return + } + + if (!subAccount.permissions.includes(permission)) { + res.status(403).json({ error: 'Forbidden', required: permission }) + return + } + + // Mark as delegated action — downstream controllers use this for audit. + req.auth.actingAsUserId = req.auth.userId + + logger.info('[SubAccount] Delegated access granted', { + parentUserId: req.auth.userId, + childUserId: targetUserId, + permission, + }) + + next() + } catch (error) { + logger.error('[SubAccount] Middleware error:', error) + res.status(500).json({ error: 'Internal server error' }) + } + } +} diff --git a/src/routes/deposit.ts b/src/routes/deposit.ts index 66d5793..e03794f 100644 --- a/src/routes/deposit.ts +++ b/src/routes/deposit.ts @@ -1,6 +1,7 @@ import { Router, Request, Response } from 'express' import { z } from 'zod' import { requireAuth } from '../middleware/authenticate' +import { requireSubAccountPermission } from '../middleware/subAccount' import { validate } from '../middleware/validate' import { processOnChainTransaction } from '../controllers/transaction-controller' @@ -18,6 +19,7 @@ router.post( '/', requireAuth, validate({ body: depositSchema, errorMessage: 'Validation error' }), + requireSubAccountPermission('DEPOSIT'), async (req: Request, res: Response) => { return processOnChainTransaction(req, res, 'DEPOSIT') } diff --git a/src/routes/sub-accounts.ts b/src/routes/sub-accounts.ts new file mode 100644 index 0000000..a208a83 --- /dev/null +++ b/src/routes/sub-accounts.ts @@ -0,0 +1,186 @@ +import { Router, Request, Response } from 'express' +import { z } from 'zod' +import { SubAccountPermission } from '@prisma/client' +import { requireAuth } from '../middleware/authenticate' +import { validate } from '../middleware/validate' +import db from '../db' +import { logger } from '../utils/logger' + +const router = Router() + +const PERMISSION_VALUES = Object.values(SubAccountPermission) + +const createSubAccountSchema = z.object({ + childUserId: z.string().uuid(), + permissions: z + .array(z.enum(PERMISSION_VALUES as [string, ...string[]])) + .min(1) + .max(4), +}) + +const updatePermissionsSchema = z.object({ + permissions: z + .array(z.enum(PERMISSION_VALUES as [string, ...string[]])) + .min(1) + .max(4), +}) + +// ── POST / — create a sub-account relationship ────────────────────────────── +router.post( + '/', + requireAuth, + validate({ body: createSubAccountSchema, errorMessage: 'Validation error' }), + async (req: Request, res: Response) => { + const { childUserId, permissions } = req.body + const parentUserId = req.auth!.userId + + // Prevent self-referencing + if (parentUserId === childUserId) { + res.status(400).json({ error: 'Cannot create sub-account with yourself' }) + return + } + + // Verify child user exists + const childUser = await db.user.findUnique({ + where: { id: childUserId }, + select: { id: true }, + }) + if (!childUser) { + res.status(404).json({ error: 'Child user not found' }) + return + } + + // Prevent chained sub-accounts: child must not already be a parent + const childIsParent = await db.subAccount.findFirst({ + where: { parentUserId: childUserId, status: 'ACTIVE' }, + select: { id: true }, + }) + if (childIsParent) { + res + .status(400) + .json({ error: 'Chained sub-account relationships are not allowed' }) + return + } + + // Check for existing relationship + const existing = await db.subAccount.findUnique({ + where: { + parentUserId_childUserId: { parentUserId, childUserId }, + }, + select: { id: true, status: true }, + }) + if (existing) { + if (existing.status === 'ACTIVE') { + res + .status(409) + .json({ error: 'Sub-account relationship already exists' }) + return + } + // Re-activate a revoked relationship + const updated = await db.subAccount.update({ + where: { id: existing.id }, + data: { + permissions: permissions as SubAccountPermission[], + status: 'ACTIVE', + revokedAt: null, + }, + }) + + logger.info('[SubAccount] Re-activated sub-account', { + parentUserId, + childUserId, + permissions, + }) + + res.status(201).json({ subAccount: updated }) + return + } + + const subAccount = await db.subAccount.create({ + data: { + parentUserId, + childUserId, + permissions: permissions as SubAccountPermission[], + }, + }) + + logger.info('[SubAccount] Created sub-account', { + parentUserId, + childUserId, + permissions, + }) + + res.status(201).json({ subAccount }) + } +) + +// ── PATCH /:id/permissions — adjust permissions ──────────────────────────── +router.patch( + '/:id/permissions', + requireAuth, + validate({ body: updatePermissionsSchema, errorMessage: 'Validation error' }), + async (req: Request, res: Response) => { + const { id } = req.params + const { permissions } = req.body + const parentUserId = req.auth!.userId + + const subAccount = await db.subAccount.findUnique({ where: { id } }) + if (!subAccount) { + res.status(404).json({ error: 'Sub-account not found' }) + return + } + + if (subAccount.parentUserId !== parentUserId) { + res.status(403).json({ error: 'Forbidden' }) + return + } + + const updated = await db.subAccount.update({ + where: { id }, + data: { permissions: permissions as SubAccountPermission[] }, + }) + + logger.info('[SubAccount] Updated permissions', { + parentUserId, + childUserId: subAccount.childUserId, + oldPermissions: subAccount.permissions, + newPermissions: permissions, + }) + + res.json({ subAccount: updated }) + } +) + +// ── DELETE /:id — revoke ─────────────────────────────────────────────────── +router.delete('/:id', requireAuth, async (req: Request, res: Response) => { + const { id } = req.params + const parentUserId = req.auth!.userId + + const subAccount = await db.subAccount.findUnique({ where: { id } }) + if (!subAccount) { + res.status(404).json({ error: 'Sub-account not found' }) + return + } + + if (subAccount.parentUserId !== parentUserId) { + res.status(403).json({ error: 'Forbidden' }) + return + } + + const revoked = await db.subAccount.update({ + where: { id }, + data: { + status: 'REVOKED', + revokedAt: new Date(), + }, + }) + + logger.info('[SubAccount] Revoked sub-account', { + parentUserId, + childUserId: subAccount.childUserId, + }) + + res.json({ subAccount: revoked }) +}) + +export default router diff --git a/src/routes/withdraw.ts b/src/routes/withdraw.ts index cb047df..f2d0f17 100644 --- a/src/routes/withdraw.ts +++ b/src/routes/withdraw.ts @@ -1,6 +1,7 @@ import { Router, Request, Response } from 'express' import { z } from 'zod' import { requireAuth } from '../middleware/authenticate' +import { requireSubAccountPermission } from '../middleware/subAccount' import { validate } from '../middleware/validate' import { processOnChainTransaction } from '../controllers/transaction-controller' @@ -18,6 +19,7 @@ router.post( '/', requireAuth, validate({ body: withdrawSchema, errorMessage: 'Validation error' }), + requireSubAccountPermission('WITHDRAW'), async (req: Request, res: Response) => { return processOnChainTransaction(req, res, 'WITHDRAWAL') } diff --git a/src/types/express.d.ts b/src/types/express.d.ts index 42a1a5d..30baec5 100644 --- a/src/types/express.d.ts +++ b/src/types/express.d.ts @@ -11,6 +11,7 @@ declare global { sessionId: string walletAddress: string network: Network + actingAsUserId?: string // set by requireSubAccountPermission when parent acts on child } } } diff --git a/tests/unit/middleware/subAccount.test.ts b/tests/unit/middleware/subAccount.test.ts new file mode 100644 index 0000000..cc80f36 --- /dev/null +++ b/tests/unit/middleware/subAccount.test.ts @@ -0,0 +1,342 @@ +import { Request, Response, NextFunction } from 'express' +import { Network } from '@prisma/client' +import { requireSubAccountPermission } from '../../../src/middleware/subAccount' +import db from '../../../src/db' +import { logger } from '../../../src/utils/logger' + +jest.mock('../../../src/db', () => ({ + __esModule: true, + default: { + subAccount: { + findUnique: jest.fn(), + }, + }, +})) +jest.mock('../../../src/utils/logger') + +type AuthPayload = { + userId: string + sessionId: string + walletAddress: string + network: Network + actingAsUserId?: string +} + +type AuthenticatedRequest = Partial & { + userId?: string + stellarPubKey?: string + auth?: AuthPayload + params?: Record + body?: Record +} + +describe('requireSubAccountPermission Middleware', () => { + let req: AuthenticatedRequest + let res: Partial + let next: NextFunction + + beforeEach(() => { + req = { + headers: {}, + params: {}, + body: {}, + } + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + } + next = jest.fn() + jest.clearAllMocks() + }) + + function makeAuth(userId: string): AuthPayload { + return { + userId, + sessionId: 'session1', + walletAddress: 'GDZST3XVCDTUJ76ZAV2HA72KYXM4Y5KLTMPQWLBQ3VBLGR4A5YNWHA63', + network: Network.MAINNET, + } + } + + describe('self-access passthrough', () => { + it('should allow self-access via body.userId without DB lookup', async () => { + req.auth = makeAuth('user1') + req.body = { userId: 'user1' } + + const middleware = requireSubAccountPermission('DEPOSIT') + await middleware(req as Request, res as Response, next) + + expect(next).toHaveBeenCalled() + expect(res.status).not.toHaveBeenCalled() + expect(db.subAccount.findUnique).not.toHaveBeenCalled() + expect(req.auth.actingAsUserId).toBeUndefined() + }) + + it('should allow self-access via params.userId without DB lookup', async () => { + req.auth = makeAuth('user1') + req.params = { userId: 'user1' } + + const middleware = requireSubAccountPermission('WITHDRAW') + await middleware(req as Request, res as Response, next) + + expect(next).toHaveBeenCalled() + expect(res.status).not.toHaveBeenCalled() + expect(db.subAccount.findUnique).not.toHaveBeenCalled() + }) + + it('should allow access when no target userId is specified', async () => { + req.auth = makeAuth('user1') + req.body = {} + req.params = {} + + const middleware = requireSubAccountPermission('VIEW') + await middleware(req as Request, res as Response, next) + + expect(next).toHaveBeenCalled() + expect(db.subAccount.findUnique).not.toHaveBeenCalled() + }) + }) + + describe('delegated access - happy path', () => { + it('should allow access with valid ACTIVE sub-account and required permission', async () => { + req.auth = makeAuth('parent1') + req.body = { userId: 'child1' } + + ;(db.subAccount.findUnique as jest.Mock).mockResolvedValue({ + id: 'sub1', + parentUserId: 'parent1', + childUserId: 'child1', + permissions: ['DEPOSIT', 'VIEW'], + status: 'ACTIVE', + }) + + const middleware = requireSubAccountPermission('DEPOSIT') + await middleware(req as Request, res as Response, next) + + expect(db.subAccount.findUnique).toHaveBeenCalledWith({ + where: { + parentUserId_childUserId: { + parentUserId: 'parent1', + childUserId: 'child1', + }, + }, + }) + expect(next).toHaveBeenCalled() + expect(res.status).not.toHaveBeenCalled() + expect(req.auth.actingAsUserId).toBe('parent1') + expect(logger.info).toHaveBeenCalledWith( + '[SubAccount] Delegated access granted', + expect.objectContaining({ + parentUserId: 'parent1', + childUserId: 'child1', + permission: 'DEPOSIT', + }) + ) + }) + + it('should read target userId from params when body is absent', async () => { + req.auth = makeAuth('parent1') + req.params = { userId: 'child1' } + req.body = {} + + ;(db.subAccount.findUnique as jest.Mock).mockResolvedValue({ + id: 'sub1', + parentUserId: 'parent1', + childUserId: 'child1', + permissions: ['WITHDRAW'], + status: 'ACTIVE', + }) + + const middleware = requireSubAccountPermission('WITHDRAW') + await middleware(req as Request, res as Response, next) + + expect(next).toHaveBeenCalled() + expect(req.auth.actingAsUserId).toBe('parent1') + }) + }) + + describe('delegated access - denial paths', () => { + it('should reject when no sub-account relationship exists', async () => { + req.auth = makeAuth('parent1') + req.body = { userId: 'child1' } + + ;(db.subAccount.findUnique as jest.Mock).mockResolvedValue(null) + + const middleware = requireSubAccountPermission('DEPOSIT') + await middleware(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(403) + expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' }) + expect(next).not.toHaveBeenCalled() + expect(req.auth.actingAsUserId).toBeUndefined() + }) + + it('should reject when sub-account status is REVOKED', async () => { + req.auth = makeAuth('parent1') + req.body = { userId: 'child1' } + + ;(db.subAccount.findUnique as jest.Mock).mockResolvedValue({ + id: 'sub1', + parentUserId: 'parent1', + childUserId: 'child1', + permissions: ['DEPOSIT'], + status: 'REVOKED', + revokedAt: new Date(), + }) + + const middleware = requireSubAccountPermission('DEPOSIT') + await middleware(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(403) + expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' }) + expect(next).not.toHaveBeenCalled() + }) + + it('should reject when permission is not in the granted set', async () => { + req.auth = makeAuth('parent1') + req.body = { userId: 'child1' } + + ;(db.subAccount.findUnique as jest.Mock).mockResolvedValue({ + id: 'sub1', + parentUserId: 'parent1', + childUserId: 'child1', + permissions: ['VIEW'], + status: 'ACTIVE', + }) + + const middleware = requireSubAccountPermission('WITHDRAW') + await middleware(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(403) + expect(res.json).toHaveBeenCalledWith({ + error: 'Forbidden', + required: 'WITHDRAW', + }) + expect(next).not.toHaveBeenCalled() + }) + + it('should reject when sub-account row is for a different parent', async () => { + req.auth = makeAuth('parent1') + req.body = { userId: 'child1' } + + // The findUnique uses the composite key, so if the row doesn't exist + // for this parent-child pair, it returns null + ;(db.subAccount.findUnique as jest.Mock).mockResolvedValue(null) + + const middleware = requireSubAccountPermission('DEPOSIT') + await middleware(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(403) + expect(next).not.toHaveBeenCalled() + }) + }) + + describe('revocation takes effect immediately', () => { + it('should block access on the very next request after revocation', async () => { + req.auth = makeAuth('parent1') + req.body = { userId: 'child1' } + + // First call: ACTIVE + ;(db.subAccount.findUnique as jest.Mock).mockResolvedValueOnce({ + id: 'sub1', + parentUserId: 'parent1', + childUserId: 'child1', + permissions: ['DEPOSIT'], + status: 'ACTIVE', + }) + + const middleware = requireSubAccountPermission('DEPOSIT') + await middleware(req as Request, res as Response, next) + expect(next).toHaveBeenCalledTimes(1) + + // Second call: REVOKED (simulates revocation happened between requests) + jest.clearAllMocks() + next = jest.fn() + ;(db.subAccount.findUnique as jest.Mock).mockResolvedValueOnce({ + id: 'sub1', + parentUserId: 'parent1', + childUserId: 'child1', + permissions: ['DEPOSIT'], + status: 'REVOKED', + revokedAt: new Date(), + }) + + await middleware(req as Request, res as Response, next) + expect(res.status).toHaveBeenCalledWith(403) + expect(next).not.toHaveBeenCalled() + }) + }) + + describe('permissions are independent flags', () => { + it('should not grant WITHDRAW when only DEPOSIT is granted', async () => { + req.auth = makeAuth('parent1') + req.body = { userId: 'child1' } + + ;(db.subAccount.findUnique as jest.Mock).mockResolvedValue({ + id: 'sub1', + parentUserId: 'parent1', + childUserId: 'child1', + permissions: ['DEPOSIT'], + status: 'ACTIVE', + }) + + const middleware = requireSubAccountPermission('WITHDRAW') + await middleware(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(403) + expect(next).not.toHaveBeenCalled() + }) + + it('should not grant MANAGE_STRATEGY when DEPOSIT and VIEW are granted', async () => { + req.auth = makeAuth('parent1') + req.body = { userId: 'child1' } + + ;(db.subAccount.findUnique as jest.Mock).mockResolvedValue({ + id: 'sub1', + parentUserId: 'parent1', + childUserId: 'child1', + permissions: ['DEPOSIT', 'VIEW'], + status: 'ACTIVE', + }) + + const middleware = requireSubAccountPermission('MANAGE_STRATEGY') + await middleware(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(403) + expect(next).not.toHaveBeenCalled() + }) + }) + + describe('error handling', () => { + it('should return 500 on database errors', async () => { + req.auth = makeAuth('parent1') + req.body = { userId: 'child1' } + + const dbError = new Error('Database connection failed') + ;(db.subAccount.findUnique as jest.Mock).mockRejectedValue(dbError) + + const middleware = requireSubAccountPermission('DEPOSIT') + await middleware(req as Request, res as Response, next) + + expect(logger.error).toHaveBeenCalledWith( + '[SubAccount] Middleware error:', + dbError + ) + expect(res.status).toHaveBeenCalledWith(500) + expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' }) + expect(next).not.toHaveBeenCalled() + }) + + it('should reject when req.auth is missing (defensive)', async () => { + req.auth = undefined + req.body = { userId: 'child1' } + + const middleware = requireSubAccountPermission('DEPOSIT') + await middleware(req as Request, res as Response, next) + + expect(res.status).toHaveBeenCalledWith(401) + expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized' }) + expect(next).not.toHaveBeenCalled() + }) + }) +}) diff --git a/tests/unit/routes/sub-accounts.test.ts b/tests/unit/routes/sub-accounts.test.ts new file mode 100644 index 0000000..db0a15d --- /dev/null +++ b/tests/unit/routes/sub-accounts.test.ts @@ -0,0 +1,355 @@ +process.env.NODE_ENV = 'test' + +import { Request, Response, NextFunction } from 'express' +import { Network } from '@prisma/client' +import subAccountsRouter from '../../../src/routes/sub-accounts' +import db from '../../../src/db' +import { logger } from '../../../src/utils/logger' +import express from 'express' + +jest.mock('../../../src/db', () => ({ + __esModule: true, + default: { + user: { + findUnique: jest.fn(), + }, + subAccount: { + findFirst: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + }, +})) +jest.mock('../../../src/utils/logger') + +// Mock requireAuth to inject a controlled auth context +jest.mock('../../../src/middleware/authenticate', () => ({ + requireAuth: (req: Request, _res: Response, next: NextFunction) => { + if (!req.headers?.authorization) { + _res.status(401).json({ error: 'Unauthorized' }) + return + } + req.auth = { + userId: (req as any)._testUserId ?? 'parent1', + sessionId: 'session1', + walletAddress: 'GDZST3XVCDTUJ76ZAV2HA72KYXM4Y5KLTMPQWLBQ3VBLGR4A5YNWHA63', + network: Network.MAINNET, + } + next() + }, +})) + +// Mock validate to be a passthrough +jest.mock('../../../src/middleware/validate', () => ({ + validate: () => (req: Request, _res: Response, next: NextFunction) => next(), +})) + +const app = express() +app.use(express.json()) +app.use('/sub-accounts', subAccountsRouter) + +describe('SubAccount CRUD Routes', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + function authHeader() { + return { Authorization: 'Bearer test-token' } + } + + // ── POST / ──────────────────────────────────────────────────────────────── + + describe('POST /sub-accounts', () => { + it('should create a sub-account relationship', async () => { + ;(db.user.findUnique as jest.Mock).mockResolvedValue({ id: 'child1' }) + ;(db.subAccount.findFirst as jest.Mock).mockResolvedValue(null) + ;(db.subAccount.findUnique as jest.Mock).mockResolvedValue(null) + ;(db.subAccount.create as jest.Mock).mockResolvedValue({ + id: 'sub1', + parentUserId: 'parent1', + childUserId: 'child1', + permissions: ['DEPOSIT', 'VIEW'], + status: 'ACTIVE', + createdAt: new Date(), + revokedAt: null, + }) + + const res = await request(app) + .post('/sub-accounts') + .set(authHeader()) + .send({ childUserId: 'child1', permissions: ['DEPOSIT', 'VIEW'] }) + + expect(res.status).toBe(201) + expect(res.body.subAccount.parentUserId).toBe('parent1') + expect(res.body.subAccount.childUserId).toBe('child1') + expect(res.body.subAccount.permissions).toEqual(['DEPOSIT', 'VIEW']) + expect(db.subAccount.create).toHaveBeenCalledWith({ + data: { + parentUserId: 'parent1', + childUserId: 'child1', + permissions: ['DEPOSIT', 'VIEW'], + }, + }) + }) + + it('should reject self-referencing', async () => { + const res = await request(app) + .post('/sub-accounts') + .set(authHeader()) + .send({ childUserId: 'parent1', permissions: ['VIEW'] }) + + expect(res.status).toBe(400) + expect(res.body.error).toContain('yourself') + }) + + it('should reject when child user does not exist', async () => { + ;(db.user.findUnique as jest.Mock).mockResolvedValue(null) + + const res = await request(app) + .post('/sub-accounts') + .set(authHeader()) + .send({ childUserId: 'nonexistent', permissions: ['VIEW'] }) + + expect(res.status).toBe(404) + expect(res.body.error).toContain('not found') + }) + + it('should reject chained sub-accounts', async () => { + ;(db.user.findUnique as jest.Mock).mockResolvedValue({ id: 'child1' }) + ;(db.subAccount.findFirst as jest.Mock).mockResolvedValue({ + id: 'existing', + parentUserId: 'child1', + }) + + const res = await request(app) + .post('/sub-accounts') + .set(authHeader()) + .send({ childUserId: 'child1', permissions: ['VIEW'] }) + + expect(res.status).toBe(400) + expect(res.body.error).toContain('Chained') + }) + + it('should reject duplicate ACTIVE relationship', async () => { + ;(db.user.findUnique as jest.Mock).mockResolvedValue({ id: 'child1' }) + ;(db.subAccount.findFirst as jest.Mock).mockResolvedValue(null) + ;(db.subAccount.findUnique as jest.Mock).mockResolvedValue({ + id: 'existing', + status: 'ACTIVE', + }) + + const res = await request(app) + .post('/sub-accounts') + .set(authHeader()) + .send({ childUserId: 'child1', permissions: ['VIEW'] }) + + expect(res.status).toBe(409) + expect(res.body.error).toContain('already exists') + }) + + it('should re-activate a REVOKED relationship', async () => { + ;(db.user.findUnique as jest.Mock).mockResolvedValue({ id: 'child1' }) + ;(db.subAccount.findFirst as jest.Mock).mockResolvedValue(null) + ;(db.subAccount.findUnique as jest.Mock).mockResolvedValue({ + id: 'old-sub', + status: 'REVOKED', + }) + ;(db.subAccount.update as jest.Mock).mockResolvedValue({ + id: 'old-sub', + parentUserId: 'parent1', + childUserId: 'child1', + permissions: ['DEPOSIT'], + status: 'ACTIVE', + revokedAt: null, + }) + + const res = await request(app) + .post('/sub-accounts') + .set(authHeader()) + .send({ childUserId: 'child1', permissions: ['DEPOSIT'] }) + + expect(res.status).toBe(201) + expect(db.subAccount.update).toHaveBeenCalledWith({ + where: { id: 'old-sub' }, + data: { + permissions: ['DEPOSIT'], + status: 'ACTIVE', + revokedAt: null, + }, + }) + }) + }) + + // ── PATCH /:id/permissions ──────────────────────────────────────────────── + + describe('PATCH /sub-accounts/:id/permissions', () => { + it('should update permissions for own sub-account', async () => { + ;(db.subAccount.findUnique as jest.Mock).mockResolvedValue({ + id: 'sub1', + parentUserId: 'parent1', + childUserId: 'child1', + permissions: ['VIEW'], + status: 'ACTIVE', + }) + ;(db.subAccount.update as jest.Mock).mockResolvedValue({ + id: 'sub1', + parentUserId: 'parent1', + childUserId: 'child1', + permissions: ['VIEW', 'DEPOSIT', 'WITHDRAW'], + status: 'ACTIVE', + }) + + const res = await request(app) + .patch('/sub-accounts/sub1/permissions') + .set(authHeader()) + .send({ permissions: ['VIEW', 'DEPOSIT', 'WITHDRAW'] }) + + expect(res.status).toBe(200) + expect(res.body.subAccount.permissions).toEqual([ + 'VIEW', + 'DEPOSIT', + 'WITHDRAW', + ]) + }) + + it('should return 404 when sub-account does not exist', async () => { + ;(db.subAccount.findUnique as jest.Mock).mockResolvedValue(null) + + const res = await request(app) + .patch('/sub-accounts/nonexistent/permissions') + .set(authHeader()) + .send({ permissions: ['VIEW'] }) + + expect(res.status).toBe(404) + }) + + it('should return 403 when not the owner', async () => { + ;(db.subAccount.findUnique as jest.Mock).mockResolvedValue({ + id: 'sub1', + parentUserId: 'other-parent', + childUserId: 'child1', + permissions: ['VIEW'], + status: 'ACTIVE', + }) + + const res = await request(app) + .patch('/sub-accounts/sub1/permissions') + .set(authHeader()) + .send({ permissions: ['VIEW'] }) + + expect(res.status).toBe(403) + }) + }) + + // ── DELETE /:id ─────────────────────────────────────────────────────────── + + describe('DELETE /sub-accounts/:id', () => { + it('should revoke own sub-account', async () => { + const revokedAt = new Date() + ;(db.subAccount.findUnique as jest.Mock).mockResolvedValue({ + id: 'sub1', + parentUserId: 'parent1', + childUserId: 'child1', + status: 'ACTIVE', + }) + ;(db.subAccount.update as jest.Mock).mockResolvedValue({ + id: 'sub1', + parentUserId: 'parent1', + childUserId: 'child1', + status: 'REVOKED', + revokedAt, + }) + + const res = await request(app) + .delete('/sub-accounts/sub1') + .set(authHeader()) + + expect(res.status).toBe(200) + expect(res.body.subAccount.status).toBe('REVOKED') + expect(db.subAccount.update).toHaveBeenCalledWith({ + where: { id: 'sub1' }, + data: { + status: 'REVOKED', + revokedAt: expect.any(Date), + }, + }) + }) + + it('should return 404 when sub-account does not exist', async () => { + ;(db.subAccount.findUnique as jest.Mock).mockResolvedValue(null) + + const res = await request(app) + .delete('/sub-accounts/nonexistent') + .set(authHeader()) + + expect(res.status).toBe(404) + }) + + it('should return 403 when not the owner', async () => { + ;(db.subAccount.findUnique as jest.Mock).mockResolvedValue({ + id: 'sub1', + parentUserId: 'other-parent', + childUserId: 'child1', + status: 'ACTIVE', + }) + + const res = await request(app) + .delete('/sub-accounts/sub1') + .set(authHeader()) + + expect(res.status).toBe(403) + }) + }) + + // ── Revocation takes effect immediately ────────────────────────────────── + + describe('Revocation takes effect immediately', () => { + it('should block delegated access on the next request after DELETE', async () => { + // Simulate: parent has ACTIVE sub-account, then revokes it + ;(db.subAccount.findUnique as jest.Mock) + .mockResolvedValueOnce({ + id: 'sub1', + parentUserId: 'parent1', + childUserId: 'child1', + status: 'ACTIVE', + }) + .mockResolvedValueOnce({ + id: 'sub1', + parentUserId: 'parent1', + childUserId: 'child1', + status: 'REVOKED', + revokedAt: new Date(), + }) + + // First: verify the sub-account is active by checking findUnique + const findResult1 = await db.subAccount.findUnique({ + where: { id: 'sub1' }, + }) + expect(findResult1?.status).toBe('ACTIVE') + + // DELETE (revoke) — the route itself calls findUnique again + const deleteRes = await request(app) + .delete('/sub-accounts/sub1') + .set(authHeader()) + expect(deleteRes.status).toBe(200) + + // Next request: findUnique returns REVOKED (new mock for post-revoke lookup) + ;(db.subAccount.findUnique as jest.Mock).mockResolvedValueOnce({ + id: 'sub1', + parentUserId: 'parent1', + childUserId: 'child1', + status: 'REVOKED', + revokedAt: new Date(), + }) + + const findResult2 = await db.subAccount.findUnique({ + where: { id: 'sub1' }, + }) + expect(findResult2?.status).toBe('REVOKED') + }) + }) +}) + +// Need to import request for supertest +import request from 'supertest'