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
83 changes: 83 additions & 0 deletions docs/verifier-queue-authorization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Verifier queue authorization

Verifier actions are authorization-sensitive state transitions. The actor in
the signed authentication principal is the only identity used for the check;
wallet headers, request body fields, and role-shaped client headers are not
trusted as assignment evidence.

## Decision table

| Action | Queue shape | Required assignment | Result when stale or missing |
| --- | --- | --- | --- |
| `verify` | single verifier | exact current `verifierId` | `403`, no mutation |
| `validate` | single verifier | exact current `verifierId` | `403`, no mutation |
| `approve` | single verifier | exact current `verifierId` | `403`, no mutation |
| `approve` | M-of-N | approved verifier pool | `403` for non-approved/suspended actor |

The M-of-N exception is intentional. `verifierId` identifies the queue
coordinator, while individual approvals come from the configured approved
verifier pool. M-of-N does not broaden `verify` or single-verifier
`validate`.

## Authorization boundary

`authorizeVerifierQueueAction` is a pure guard in
`src/services/verifierTransitions.ts`. It is called before mutation and
before any external side effect. It distinguishes these stable failure
codes:

- `ACTOR_REQUIRED`: the request has no authenticated actor;
- `UNASSIGNED_QUEUE_ITEM`: no current verifier owns the item;
- `STALE_ASSIGNMENT`: the item was reassigned after the actor received it;
- `ALREADY_SETTLED`: a terminal item is being verified or validated again;
- `INVALID_TRANSITION`: the requested lifecycle edge is not monotonic.

The guard does not accept a proposed assignee from the request. A reassignment
therefore invalidates an old queue response immediately, even if that old
response is replayed with a fresh HTTP request.

## Atomicity and auditability

Routes perform the guard before changing the compatibility in-memory milestone
record or inserting an approval. The lifecycle service performs the same
guard when an actor is supplied, so direct service callers cannot skip the
authorization boundary. Failed guards do not increment the lifecycle
sequence, set `verified`, insert an approval, or emit a success event.

Every successful state-changing path emits one ordered milestone event:

1. `milestone.verified` for the direct verification endpoint;
2. `milestone.validated` for evidence-backed validation;
3. `milestone.approval.recorded` for each approval vote;
4. `milestone.settled` when the approval threshold settles the milestone;
5. `milestone.lifecycle.*` for explicit lifecycle transitions.

The event ledger is append-only for the process lifetime, has a monotonically
increasing per-milestone sequence, and is reset only by test fixtures. This
makes the order of accepted state changes observable without exposing
evidence hashes or other sensitive values.

## Operational trade-offs

The compatibility milestone service remains synchronous because existing
clients and tests depend on its in-memory representation. The authorization
guard is deliberately synchronous and side-effect-free for that reason. The
database-backed approval insert still uses its unique constraint as the final
concurrency barrier; the route-level guard supplies the authorization barrier
before that insert. A future database-native queue consumer should call the
same guard after selecting the row with a transaction lock and include its
audit insert in the transaction.

## Test matrix

The regression suite covers:

- current assignee success for all single-verifier actions;
- missing actor and unassigned queue item failures;
- stale actor failures after reassignment;
- M-of-N approval-pool behavior;
- settled-item rejection;
- all permitted lifecycle edges;
- backward, self, and skipped lifecycle transitions;
- stable machine-readable error details.

45 changes: 43 additions & 2 deletions src/routes/milestones.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
getMilestoneById,
verifyMilestone,
validateMilestone,
validateMilestoneMultiVerifier,
addMilestoneEvent,
allMilestonesVerified,
allMilestonesMetThreshold,
} from '../services/milestones.js'
Expand Down Expand Up @@ -42,6 +44,7 @@ import {
DuplicateVerifierVoteError,
getVerifierProfile,
} from '../services/verifiers.js'
import { VerifierAuthorizationError } from '../services/verifierTransitions.js'

const milestoneRepo = new MilestoneRepositoryEnhanced(db)

Expand Down Expand Up @@ -308,7 +311,17 @@ milestonesRouter.patch('/:id/verify', authenticate, requireWalletIdentity, requi
throw AppError.notFound('Milestone not found')
}

const verified = verifyMilestone(id)
let verified
try {
verified = verifyMilestone(id, actorUserId)
} catch (error) {
if (error instanceof VerifierAuthorizationError) {
throw error.code === 'ALREADY_SETTLED'
? AppError.conflict(error.message)
: AppError.forbidden(error.message)
}
throw error
}
if (!verified) {
throw AppError.notFound('Milestone not found')
}
Expand Down Expand Up @@ -370,7 +383,10 @@ milestonesRouter.post('/:id/validate', authenticate, requireWalletIdentity, requ
if (result.error === 'Milestone already validated') {
throw AppError.conflict('Milestone already validated')
}
if (result.error === 'Unauthorized: only assigned verifier can validate') {
if (/already settled|already validated/i.test(result.error ?? '')) {
throw AppError.conflict(result.error!)
}
if (/assigned verifier|not assigned/i.test(result.error ?? '')) {
throw AppError.forbidden('Unauthorized: only assigned verifier can validate')
}
throw AppError.badRequest(result.error!)
Expand Down Expand Up @@ -434,6 +450,15 @@ milestonesRouter.post('/:id/approve', authenticate, requireWalletIdentity, requi
throw AppError.forbidden('Only approved verifiers may cast milestone approvals')
}

const authorization = validateMilestoneMultiVerifier(
id,
verifierUserId,
verifier?.status,
)
if (!authorization.success) {
throw AppError.forbidden(authorization.error ?? 'Verifier is not authorized for this queue item')
}

// Check if verifier has already voted (duplicate vote prevention)
const hasVoted = await hasVerifierVoted(id, verifierUserId)

Expand All @@ -453,6 +478,14 @@ milestonesRouter.post('/:id/approve', authenticate, requireWalletIdentity, requi
// Record the approval
const approval = await recordMilestoneApproval(id, verifierUserId, approvalStatus as any)

addMilestoneEvent({
userId: verifierUserId,
vaultId,
name: 'milestone.approval.recorded',
status: 'success',
timestamp: new Date().toISOString(),
})

// Get updated approval progress
const approvalProgress = await getMilestoneApprovalProgress(id, approvalThreshold, totalVerifiers)

Expand All @@ -466,6 +499,14 @@ milestonesRouter.post('/:id/approve', authenticate, requireWalletIdentity, requi
milestone.verifiedAt = new Date().toISOString()
milestone.verifiedBy = verifierUserId

addMilestoneEvent({
userId: verifierUserId,
vaultId,
name: 'milestone.settled',
status: 'success',
timestamp: milestone.verifiedAt,
})

// Build approval/rejection counts for veto-aware vault check
const vaultMilestones = getMilestonesByVaultId(vaultId)
const approvalCounts: Record<string, number> = {}
Expand Down
50 changes: 45 additions & 5 deletions src/services/milestones.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export interface Milestone {
dueDate: string | null
}

import {
authorizeVerifierQueueAction,
assertVerifierLifecycleTransition,
} from './verifierTransitions.js'

const milestonesTable: Milestone[] = []

export const createMilestone = (vaultId: string, description: string, verifierId?: string | null, dueDate?: string | null): Milestone => {
Expand Down Expand Up @@ -40,21 +45,44 @@ export const getMilestoneById = (id: string): Milestone | undefined => {
return milestonesTable.find((m) => m.id === id)
}

export const verifyMilestone = (id: string): Milestone | null => {
export const verifyMilestone = (id: string, verifierUserId?: string): Milestone | null => {
const milestone = milestonesTable.find((m) => m.id === id)
if (!milestone) return null

if (verifierUserId !== undefined) {
authorizeVerifierQueueAction(milestone, verifierUserId, 'verify')
}

milestone.verified = true
milestone.verifiedAt = new Date().toISOString()
milestone.verifiedBy = verifierUserId ?? milestone.verifiedBy

if (verifierUserId !== undefined) {
addMilestoneEvent({
userId: verifierUserId,
vaultId: milestone.vaultId,
name: 'milestone.verified',
status: 'success',
timestamp: new Date().toISOString(),
})
}
return milestone
}

export const validateMilestone = (id: string, validatorUserId: string, evidenceHash: string): { success: boolean, milestone?: Milestone, error?: string } => {
const milestone = milestonesTable.find((m) => m.id === id)
if (!milestone) return { success: false, error: 'Milestone not found' }

if (milestone.verifierId && milestone.verifierId !== validatorUserId) {
return { success: false, error: 'Unauthorized: only assigned verifier can validate' }
// Preserve the public replay contract for the current assignee while still
// checking assignment before an unverified item can be mutated.
if (milestone.verified && milestone.verifierId === validatorUserId) {
return { success: false, error: 'Milestone already validated' }
}

try {
authorizeVerifierQueueAction(milestone, validatorUserId, 'validate')
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Verifier is not authorized' }
}

if (milestone.verified) {
Expand Down Expand Up @@ -157,6 +185,16 @@ export const transitionMilestone = (

const from: MilestoneLifecycleState = lifecycleState[id] ?? 'created'

// Submission is created by the vault workflow, not a verifier queue action.
// Authorization begins when a verifier changes the submitted item.
if (opts?.actor !== undefined && to !== 'submitted') {
const action = to === 'settled' ? 'approve' : 'validate'
// A replay must be acknowledged before checking the now-advanced state.
if (!opts.idempotencyKey || !appliedIdempotencyKeys[id]?.has(opts.idempotencyKey)) {
assertVerifierLifecycleTransition(milestone, opts.actor, action, from, to)
}
}

// Duplicate-request safety: the same idempotency key may only apply once
// per milestone. A retry with the same key is acknowledged without
// re-applying the transition (exactly-once semantics). Checked before the
Expand Down Expand Up @@ -412,10 +450,12 @@ export const validateMilestoneMultiVerifier = (
}

// For thresholds > 1, multiple verifiers should be able to approve
if (milestone.approvalThreshold === 1 && milestone.verifierId && milestone.verifierId !== validatorUserId) {
try {
authorizeVerifierQueueAction(milestone, validatorUserId, 'approve')
} catch (error) {
return {
success: false,
error: 'Unauthorized: only assigned verifier can validate this milestone',
error: error instanceof Error ? error.message : 'Verifier is not authorized',
milestone,
canApprove: false,
}
Expand Down
Loading
Loading