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>
This commit is contained in:
162
src/middleware/tierEnforcement.ts
Normal file
162
src/middleware/tierEnforcement.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
})();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user