Files
sentryagent-idp/src/middleware/tierEnforcement.ts
SentryAgent.ai Developer eea885db04 feat(phase-6): WS3+WS4+WS6 — Analytics, API Tiers, AGNTCY Compliance
WS3 — Advanced Analytics Dashboard:
- DB migration: analytics_events table (tenant_id, date, metric_type, count)
- AnalyticsService: recordEvent (fire-and-forget), getTokenTrend, getAgentActivity, getAgentUsageSummary
- Analytics hooks in OAuth2Service (token_issued) and AgentService (agent_registered/deactivated)
- AnalyticsController + routes/analytics.ts (gated by ANALYTICS_ENABLED flag)
- Portal: TokenTrendChart (recharts LineChart), AgentHeatmap (recharts heatmap), /analytics page

WS4 — API Gateway Tiers:
- DB migration: tenant_tiers table; src/config/tiers.ts (free/pro/enterprise limits)
- TierService: getStatus, initiateUpgrade (Stripe), applyUpgrade; TierLimitError in errors.ts
- tierEnforcement middleware (Redis-backed daily call/token counters; TIER_ENFORCEMENT flag)
- Agent count enforcement in AgentService.create()
- Stripe webhook updated to call TierService.applyUpgrade() on checkout.session.completed
- TierController + routes/tiers.ts; Portal: /settings/tier page with upgrade flow

WS6 — AGNTCY Compliance Certification:
- ComplianceService: generateReport() (Redis-cached 5 min), exportAgentCards()
- Compliance sections: agent-identity (DID + credential expiry checks), audit-trail (Merkle chain)
- ComplianceController updated with getComplianceReport, exportAgentCards handlers
- routes/compliance.ts: new AGNTCY routes (gated by COMPLIANCE_ENABLED flag); SOC2 routes unaffected

QA:
- 28 new unit tests: AnalyticsService (8), TierService (9), ComplianceService (11) — all pass
- 673 total unit tests passing; 0 TypeScript errors across API and portal
- AGNTCY conformance test suite at tests/agntcy-conformance/ (4 protocol tests)
- Portal builds cleanly: 9 routes including /analytics and /settings/tier
- Feature flags verified: ANALYTICS_ENABLED, TIER_ENFORCEMENT, COMPLIANCE_ENABLED

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 02:20:09 +00:00

163 lines
5.5 KiB
TypeScript

/**
* Tier enforcement middleware for SentryAgent.ai AgentIdP.
*
* Enforces per-tenant daily API call limits based on the tenant's tier.
* Uses Redis keys `rate:tier:calls:<org_id>` with TTL aligned to UTC midnight.
*
* Behaviour:
* - Skipped entirely when TIER_ENFORCEMENT env var is 'false'.
* - Skipped for enterprise tenants (no limits apply).
* - On Redis unavailability: logs the error and proceeds (fail-open).
* - Sets X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset on every response.
* - Returns HTTP 429 with Retry-After header when limit is exceeded.
*/
import { Request, Response, NextFunction, RequestHandler } from 'express';
import type { RedisClientType } from 'redis';
import { Pool } from 'pg';
import { TIER_CONFIG, TierName, isTierName } from '../config/tiers.js';
import { TierLimitError } from '../utils/errors.js';
/** Redis key prefix for daily API call counters. */
const CALLS_KEY_PREFIX = 'rate:tier:calls:';
/**
* Returns the number of seconds remaining until the next UTC midnight.
* Used as the Redis key TTL and the Retry-After value on rejection.
*
* @returns Seconds until next UTC midnight (minimum 1).
*/
function secondsUntilUtcMidnight(): number {
const now = Date.now();
const midnight = new Date();
midnight.setUTCHours(24, 0, 0, 0);
return Math.max(1, Math.ceil((midnight.getTime() - now) / 1000));
}
/**
* Returns the Unix timestamp (seconds) of the next UTC midnight.
* Used for the X-RateLimit-Reset header.
*
* @returns Unix timestamp of next UTC midnight.
*/
function nextUtcMidnightTimestamp(): number {
const midnight = new Date();
midnight.setUTCHours(24, 0, 0, 0);
return Math.ceil(midnight.getTime() / 1000);
}
/**
* Fetches the tenant's current tier from the organizations table.
* Falls back to 'free' when the tenant row is not found.
*
* @param pool - PostgreSQL connection pool.
* @param orgId - The organization ID.
* @returns The tenant's current TierName.
*/
async function getTenantTier(pool: Pool, orgId: string): Promise<TierName> {
const result = await pool.query<{ tier: string }>(
`SELECT tier FROM organizations WHERE organization_id = $1 LIMIT 1`,
[orgId],
);
if (result.rows.length === 0) return 'free';
const tier = result.rows[0].tier;
return isTierName(tier) ? tier : 'free';
}
/**
* Creates the tier enforcement middleware.
*
* Designed to run after auth middleware (req.user must be populated).
* Unauthenticated requests pass through unaffected.
*
* @param pool - PostgreSQL connection pool (used to look up tenant tier).
* @param redis - Redis client (used for rate counter storage).
* @returns Express RequestHandler.
*/
export function createTierEnforcementMiddleware(
pool: Pool,
redis: RedisClientType,
): RequestHandler {
return (req: Request, res: Response, next: NextFunction): void => {
// Feature flag: bypass all tier enforcement when disabled
if (process.env['TIER_ENFORCEMENT'] === 'false') {
// Still set headers reflecting unlimited limits
res.setHeader('X-RateLimit-Limit', 'unlimited');
res.setHeader('X-RateLimit-Remaining', 'unlimited');
res.setHeader('X-RateLimit-Reset', nextUtcMidnightTimestamp());
next();
return;
}
// Only enforce for authenticated requests
if (!req.user?.organization_id) {
next();
return;
}
const orgId = req.user.organization_id;
void (async (): Promise<void> => {
try {
const tier = await getTenantTier(pool, orgId);
// Enterprise tenants bypass all limits
if (tier === 'enterprise') {
res.setHeader('X-RateLimit-Limit', 'unlimited');
res.setHeader('X-RateLimit-Remaining', 'unlimited');
res.setHeader('X-RateLimit-Reset', nextUtcMidnightTimestamp());
next();
return;
}
const limit = TIER_CONFIG[tier].maxCallsPerDay;
const redisKey = `${CALLS_KEY_PREFIX}${orgId}`;
const ttl = secondsUntilUtcMidnight();
const resetAt = nextUtcMidnightTimestamp();
let currentCount: number;
try {
// Atomically increment and set TTL aligned to UTC midnight.
// INCR returns the new value after increment.
const newCount = await redis.incr(redisKey);
currentCount = newCount;
// Set TTL only on the first increment to avoid resetting the window
// on every request. If the key was brand new, newCount === 1.
if (newCount === 1) {
await redis.expire(redisKey, ttl);
}
} catch (redisErr) {
// Redis unavailable — fail-open: log and proceed without rate limiting
// eslint-disable-next-line no-console
console.error('[tierEnforcement] Redis error — proceeding fail-open:', redisErr);
res.setHeader('X-RateLimit-Limit', limit);
res.setHeader('X-RateLimit-Remaining', 0);
res.setHeader('X-RateLimit-Reset', resetAt);
next();
return;
}
const remaining = Math.max(0, limit - currentCount);
// Set rate limit headers on all responses
res.setHeader('X-RateLimit-Limit', limit);
res.setHeader('X-RateLimit-Remaining', remaining);
res.setHeader('X-RateLimit-Reset', resetAt);
// Reject if the new count exceeds the limit
if (currentCount > limit) {
res.setHeader('Retry-After', ttl);
next(new TierLimitError('API call', limit, { orgId, tier }));
return;
}
next();
} catch (err) {
next(err);
}
})();
};
}