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
65 changes: 19 additions & 46 deletions backend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2560,10 +2560,10 @@ export async function createApp(options = {}) {
app.get(`${prefix}/deprecations`, rateLimiter, (_req, res) =>
res.json({ deprecations: DEPRECATION_REGISTRY }),
);
app.get(`${prefix}/audit-logs`, rateLimiter, ...guard, listAuditLogs);
app.get(`${prefix}/audit-logs`, rateLimiter, ...guard, requireScope('campaigns:read'), listAuditLogs);
app.get(`${prefix}/admin/audit/verify`, rateLimiter, requireMasterKey, verifyAuditChain);
app.get(`${prefix}/indexer/cursor`, rateLimiter, getIndexerCursorState);
app.post(`${prefix}/indexer/cursor`, rateLimiter, ...guard, setIndexerCursorState);
app.post(`${prefix}/indexer/cursor`, rateLimiter, ...guard, requireScope('campaigns:write'), setIndexerCursorState);
app.post(
`${prefix}/campaigns`,
rateLimiter,
Expand Down Expand Up @@ -2608,57 +2608,29 @@ export async function createApp(options = {}) {
contentModerationMiddleware,
updateCampaign,
);
app.delete(
`${prefix}/campaigns/:id`,
rateLimiter,
...guard,
requireScope('campaigns:write'),
deleteCampaign,
);
app.put(
`${prefix}/campaigns/:id`,
rateLimiter,
idempotencyMiddleware,
requireApiKey,
updateCampaign,
);
app.put(
`${prefix}/campaigns/:id/publish`,
rateLimiter,
idempotencyMiddleware,
requireApiKey,
...guard,
requireScope('campaigns:write'),
publishCampaign,
);
app.put(
`${prefix}/campaigns/:id/archive`,
rateLimiter,
idempotencyMiddleware,
requireApiKey,
...guard,
requireScope('campaigns:write'),
archiveCampaign,
);
app.delete(`${prefix}/campaigns/:id`, rateLimiter, requireApiKey, deleteCampaign);
app.put(
app.delete(
`${prefix}/campaigns/:id`,
rateLimiter,
idempotencyMiddleware,
...guard,
updateCampaign,
);
app.put(
`${prefix}/campaigns/:id/publish`,
rateLimiter,
idempotencyMiddleware,
...guard,
publishCampaign,
);
app.put(
`${prefix}/campaigns/:id/archive`,
rateLimiter,
idempotencyMiddleware,
...guard,
archiveCampaign,
requireScope('campaigns:write'),
deleteCampaign,
);
app.delete(`${prefix}/campaigns/:id`, rateLimiter, ...guard, deleteCampaign);

// Soft-delete management routes
app.post(
Expand Down Expand Up @@ -2861,7 +2833,7 @@ export async function createApp(options = {}) {
});

// Tenant usage metering (Issue #574)
app.get(`${prefix}/usage`, rateLimiter, ...guard, (req, res) => {
app.get(`${prefix}/usage`, rateLimiter, ...guard, requireScope('campaigns:read'), (req, res) => {
const orgId = req.auth?.orgId;
if (!orgId) {
return res.status(403).json({
Expand Down Expand Up @@ -2909,12 +2881,13 @@ export async function createApp(options = {}) {
});

// Job dead-letter inspection / requeue (Issue #286)
app.get(`${prefix}/jobs/failed`, rateLimiter, ...guard, listFailedJobsHandler);
app.get(`${prefix}/jobs/failed`, rateLimiter, ...guard, requireScope('campaigns:read'), listFailedJobsHandler);
app.post(
`${prefix}/jobs/retry/:id`,
rateLimiter,
idempotencyMiddleware,
...guard,
requireScope('campaigns:write'),
retryFailedJobHandler,
);

Expand Down Expand Up @@ -2945,7 +2918,7 @@ export async function createApp(options = {}) {
);

// Webhook routes (Issue #287)
app.post(`${prefix}/webhooks`, rateLimiter, idempotencyMiddleware, ...guard, (req, res) => {
app.post(`${prefix}/webhooks`, rateLimiter, idempotencyMiddleware, ...guard, requireScope('campaigns:write'), (req, res) => {
const { url, events, secret } = req.body;
if (!url || !Array.isArray(events) || events.length === 0) {
return res.status(400).json({
Expand All @@ -2964,20 +2937,20 @@ export async function createApp(options = {}) {
return res.status(201).json(webhook);
});

app.get(`${prefix}/webhooks`, rateLimiter, ...guard, (req, res) => {
app.get(`${prefix}/webhooks`, rateLimiter, ...guard, requireScope('campaigns:read'), (req, res) => {
const webhooks = webhookRepository.list();
return res.json(paginateItems(webhooks, req.query));
});

app.get(`${prefix}/webhooks/:id`, rateLimiter, ...guard, (req, res) => {
app.get(`${prefix}/webhooks/:id`, rateLimiter, ...guard, requireScope('campaigns:read'), (req, res) => {
const webhook = webhookRepository.getById(req.params.id);
if (!webhook) {
return res.status(404).json({ error: 'Webhook not found', code: 'WEBHOOK_NOT_FOUND' });
}
return res.json(webhook);
});

app.put(`${prefix}/webhooks/:id`, rateLimiter, idempotencyMiddleware, ...guard, (req, res) => {
app.put(`${prefix}/webhooks/:id`, rateLimiter, idempotencyMiddleware, ...guard, requireScope('campaigns:write'), (req, res) => {
const { url, events, active } = req.body;
const before = webhookRepository.getById(req.params.id);
if (!before) {
Expand All @@ -2997,7 +2970,7 @@ export async function createApp(options = {}) {
return res.json(webhook);
});

app.delete(`${prefix}/webhooks/:id`, rateLimiter, ...guard, (req, res) => {
app.delete(`${prefix}/webhooks/:id`, rateLimiter, ...guard, requireScope('campaigns:write'), (req, res) => {
const before = webhookRepository.getById(req.params.id);
const deleted = webhookRepository.delete(req.params.id);
if (!deleted) {
Expand All @@ -3012,7 +2985,7 @@ export async function createApp(options = {}) {
return res.status(204).end();
});

app.get(`${prefix}/webhooks/:id/deliveries`, rateLimiter, ...guard, (req, res) => {
app.get(`${prefix}/webhooks/:id/deliveries`, rateLimiter, ...guard, requireScope('campaigns:read'), (req, res) => {
const webhook = webhookRepository.getById(req.params.id);
if (!webhook) {
return res.status(404).json({ error: 'Webhook not found', code: 'WEBHOOK_NOT_FOUND' });
Expand Down Expand Up @@ -3361,7 +3334,7 @@ export async function createApp(options = {}) {
},
);

app.get(`${prefix}/campaigns/:id/allowlist`, rateLimiter, ...guard, (req, res) => {
app.get(`${prefix}/campaigns/:id/allowlist`, rateLimiter, ...guard, requireScope('campaigns:read'), (req, res) => {
const campaign = campaignRepository.getById(req.params.id);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found', code: 'CAMPAIGN_NOT_FOUND' });
Expand Down
4 changes: 3 additions & 1 deletion contracts/campaign/src/fuzz_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,9 @@ proptest! {
let result = client.try_accept_admin(&impostor);
assert_eq!(result, Err(Ok(Error::Unauthorized)));

// Pending admin accepts
// Pending admin accepts after the cooling period
env.ledger()
.with_mut(|li| li.timestamp = ADMIN_ROTATION_COOLDOWN_SECS);
client.accept_admin(&new_admin);
assert_eq!(client.admin(), new_admin);
assert_eq!(client.pending_admin(), None);
Expand Down
25 changes: 22 additions & 3 deletions contracts/campaign/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ pub enum Error {
InAddressBlocklist = 126,
/// Participation fee configuration is invalid (zero amount or missing token).
InvalidFeeConfig = 127,
/// Admin rotation was requested before the 24-hour cooling period elapsed.
AdminCooldownActive = 128,
}

contractmeta!(key = "Description", val = "Trivela campaign configuration");
Expand Down Expand Up @@ -307,8 +309,11 @@ const PARTICIPANT_TTL_THRESHOLD: u32 = 100;
const PARTICIPANT_TTL_EXTEND_TO: u32 = 500;
// ── 2-step admin transfer (issue #281) ───────────────────────────────────────
const PENDING_ADMIN: Symbol = symbol_short!("padmin");
const PENDING_ADMIN_AT: Symbol = symbol_short!("padminat");
const ADMIN_PROPOSED_EVENT: Symbol = symbol_short!("aproposed");
const ADMIN_ACCEPTED_EVENT: Symbol = symbol_short!("aaccepted");
/// Mandatory cooling period before a proposed admin key takes effect (#1353).
pub const ADMIN_ROTATION_COOLDOWN_SECS: u64 = 86_400;

// ── Privacy mode (issue #544) ────────────────────────────────────────────────
// Per-campaign registration mode: open, Merkle-gated, or ZK-gated.
Expand Down Expand Up @@ -1863,7 +1868,8 @@ impl CampaignContract {
}

/// Propose a new admin (current admin only). The transfer does not take
/// effect until `accept_admin` is called by the new admin.
/// effect until `accept_admin` is called by the new admin, and only after
/// the 24-hour emergency-rotation cooling period has elapsed.
pub fn propose_admin(
env: Env,
current_admin: Address,
Expand All @@ -1875,6 +1881,9 @@ impl CampaignContract {
return Err(Error::Unauthorized);
}
env.storage().instance().set(&PENDING_ADMIN, &new_admin);
env.storage()
.instance()
.set(&PENDING_ADMIN_AT, &env.ledger().timestamp());
env.events()
.publish((ADMIN_PROPOSED_EVENT, current_admin), new_admin);
env.storage()
Expand All @@ -1884,8 +1893,8 @@ impl CampaignContract {
}

/// Accept admin role. Caller MUST be the address that the current admin
/// previously proposed via `propose_admin`. Clears the pending slot on
/// success.
/// previously proposed via `propose_admin`, and the 24-hour cooling
/// period must have elapsed. Clears the pending slot on success.
pub fn accept_admin(env: Env, new_admin: Address) -> Result<(), Error> {
new_admin.require_auth();
let pending: Address = env
Expand All @@ -1896,8 +1905,17 @@ impl CampaignContract {
if pending != new_admin {
return Err(Error::Unauthorized);
}
let proposed_at: u64 = env
.storage()
.instance()
.get(&PENDING_ADMIN_AT)
.unwrap_or(0);
if env.ledger().timestamp() < proposed_at + ADMIN_ROTATION_COOLDOWN_SECS {
return Err(Error::AdminCooldownActive);
}
env.storage().instance().set(&ADMIN, &new_admin);
env.storage().instance().remove(&PENDING_ADMIN);
env.storage().instance().remove(&PENDING_ADMIN_AT);
env.events().publish((ADMIN_ACCEPTED_EVENT,), new_admin);
env.storage()
.instance()
Expand All @@ -1913,6 +1931,7 @@ impl CampaignContract {
return Err(Error::Unauthorized);
}
env.storage().instance().remove(&PENDING_ADMIN);
env.storage().instance().remove(&PENDING_ADMIN_AT);
env.storage()
.instance()
.extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO);
Expand Down
25 changes: 23 additions & 2 deletions contracts/campaign/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1113,19 +1113,38 @@ fn setup_admin_rotation_campaign() -> (Env, CampaignContractClient<'static>, Add

#[test]
fn test_campaign_propose_and_accept_admin_happy_path() {
let (_env, client, admin, new_admin) = setup_admin_rotation_campaign();
let (env, client, admin, new_admin) = setup_admin_rotation_campaign();
assert_eq!(client.admin(), admin);
assert_eq!(client.pending_admin(), None);

client.propose_admin(&admin, &new_admin);
assert_eq!(client.pending_admin(), Some(new_admin.clone()));
assert_eq!(client.admin(), admin);

env.ledger()
.with_mut(|li| li.timestamp = ADMIN_ROTATION_COOLDOWN_SECS);
client.accept_admin(&new_admin);
assert_eq!(client.admin(), new_admin);
assert_eq!(client.pending_admin(), None);
}

#[test]
fn test_campaign_accept_admin_before_cooldown_fails() {
let (env, client, admin, new_admin) = setup_admin_rotation_campaign();
client.propose_admin(&admin, &new_admin);

env.ledger()
.with_mut(|li| li.timestamp = ADMIN_ROTATION_COOLDOWN_SECS - 1);
let result = client.try_accept_admin(&new_admin);
assert_eq!(result, Err(Ok(Error::AdminCooldownActive)));
assert_eq!(client.admin(), admin);

env.ledger()
.with_mut(|li| li.timestamp = ADMIN_ROTATION_COOLDOWN_SECS);
client.accept_admin(&new_admin);
assert_eq!(client.admin(), new_admin);
}

#[test]
fn test_campaign_propose_without_accept_keeps_old_admin() {
let (_env, client, admin, new_admin) = setup_admin_rotation_campaign();
Expand Down Expand Up @@ -1172,8 +1191,10 @@ fn test_campaign_cancel_admin_transfer_clears_pending() {
#[test]
fn test_campaign_new_admin_can_call_admin_operations() {
// Once accepted, the new admin's signature is enough to perform admin-only ops.
let (_env, client, admin, new_admin) = setup_admin_rotation_campaign();
let (env, client, admin, new_admin) = setup_admin_rotation_campaign();
client.propose_admin(&admin, &new_admin);
env.ledger()
.with_mut(|li| li.timestamp = ADMIN_ROTATION_COOLDOWN_SECS);
client.accept_admin(&new_admin);

// set_active is admin-only — was previously rejected for `admin`'s replacement
Expand Down
27 changes: 24 additions & 3 deletions contracts/rewards/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@ pub enum Error {
TimelockAlreadyQueued = 68,
/// No matching timelock entry is queued.
TimelockNotFound = 69,
/// Admin rotation was requested before the 24-hour cooling period elapsed.
AdminCooldownActive = 70,
}

// ── Issue #895: Operator delegation types ─────────────────────────────────────
Expand Down Expand Up @@ -476,9 +478,14 @@ const ADMIN_NONCE: Symbol = symbol_short!("anonce");
// `PENDING_ADMIN` holds an in-flight proposed admin; the new admin must call
// `accept_admin()` themselves to complete the rotation, eliminating the
// "wrong address, key now lost" failure mode of a one-step transfer.
// `PENDING_ADMIN_AT` records the ledger timestamp of the proposal so the
// emergency rotation path can enforce a mandatory cooling period (#1353).
const PENDING_ADMIN: Symbol = symbol_short!("padmin");
const PENDING_ADMIN_AT: Symbol = symbol_short!("padminat");
const ADMIN_PROPOSED_EVENT: Symbol = symbol_short!("aproposed");
const ADMIN_ACCEPTED_EVENT: Symbol = symbol_short!("aaccepted");
/// Mandatory cooling period before a proposed admin key takes effect.
pub const ADMIN_ROTATION_COOLDOWN_SECS: u64 = 86_400;

// ── On-chain referral rewards (issue #656 / #603) ────────────────────────────
// The referral *graph* (who referred whom) is attributed by the campaign
Expand Down Expand Up @@ -1551,7 +1558,8 @@ impl RewardsContract {
}

/// Propose a new admin (current admin only). The transfer does not take
/// effect until `accept_admin` is called by the new admin.
/// effect until `accept_admin` is called by the new admin, and only after
/// the 24-hour emergency-rotation cooling period has elapsed.
///
/// Calling again overwrites the previous pending admin, so the current
/// admin can cancel a proposal by calling `cancel_admin_transfer` or by
Expand All @@ -1563,6 +1571,9 @@ impl RewardsContract {
) -> Result<(), Error> {
require_admin(&env, &current_admin)?;
env.storage().instance().set(&PENDING_ADMIN, &new_admin);
env.storage()
.instance()
.set(&PENDING_ADMIN_AT, &env.ledger().timestamp());
env.events()
.publish((ADMIN_PROPOSED_EVENT, current_admin), new_admin);
env.storage()
Expand All @@ -1572,8 +1583,8 @@ impl RewardsContract {
}

/// Accept admin role. Caller MUST be the address that the current admin
/// previously proposed via `propose_admin`. Clears the pending slot on
/// success.
/// previously proposed via `propose_admin`, and the 24-hour cooling
/// period must have elapsed. Clears the pending slot on success.
pub fn accept_admin(env: Env, new_admin: Address) -> Result<(), Error> {
new_admin.require_auth();
let pending: Address = env
Expand All @@ -1584,8 +1595,17 @@ impl RewardsContract {
if pending != new_admin {
return Err(Error::Unauthorized);
}
let proposed_at: u64 = env
.storage()
.instance()
.get(&PENDING_ADMIN_AT)
.unwrap_or(0);
if env.ledger().timestamp() < proposed_at + ADMIN_ROTATION_COOLDOWN_SECS {
return Err(Error::AdminCooldownActive);
}
env.storage().instance().set(&ADMIN, &new_admin);
env.storage().instance().remove(&PENDING_ADMIN);
env.storage().instance().remove(&PENDING_ADMIN_AT);
env.events().publish((ADMIN_ACCEPTED_EVENT,), new_admin);
env.storage()
.instance()
Expand All @@ -1597,6 +1617,7 @@ impl RewardsContract {
pub fn cancel_admin_transfer(env: Env, current_admin: Address) -> Result<(), Error> {
require_admin(&env, &current_admin)?;
env.storage().instance().remove(&PENDING_ADMIN);
env.storage().instance().remove(&PENDING_ADMIN_AT);
env.storage()
.instance()
.extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO);
Expand Down
Loading
Loading