fix: Enforce strict tier-based access control for node endpoints

Access control hierarchy:
- Anonymous (free): db-sync read-only ONLY, no node access
- Standard (≥50 TRP): db-sync + node read (UTxOs, protocol-params)
- Elevated (≥500 TRP): everything + tx submit
- Master: unrestricted

Node endpoints now return HTTP 403 for insufficient tier:
- GET /v1/address/{addr}/utxos → requires standard+
- GET /v1/protocol-params → requires standard+
- POST /v1/tx/submit → requires elevated+ (403 for standard/anonymous)

Added require_standard_tier and require_elevated_tier dependencies.
This commit is contained in:
Sulkta 2026-03-21 09:15:40 -07:00
parent 00c44fd0f2
commit 3cfbf7ef59
2 changed files with 61 additions and 14 deletions

View file

@ -5,7 +5,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \ curl \
ca-certificates \ ca-certificates \
libsodium23 \ libsodium23 \
libsecp256k1-1 \
libnuma1 \ libnuma1 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

74
main.py
View file

@ -8,10 +8,19 @@ Known policy IDs:
- TRP: 9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05 - TRP: 9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05
- MAP: 24bd9e7b9ae3a61df79eca72fd8355d0f7767e4c55a04a0d919c019c - MAP: 24bd9e7b9ae3a61df79eca72fd8355d0f7767e4c55a04a0d919c019c
TRP Gating Tiers: Access Tiers (strictly enforced):
- 0 TRP anonymous rate limits (20 req/min) - Anonymous (0 TRP, 20 req/min):
- 50+ TRP standard tier (100 req/min) db-sync read-only ONLY. Balance, transactions, tokens, blocks, assets, pools, sync status.
- 500+ TRP elevated tier (1000 req/min) NO node access whatsoever.
- Standard (50 TRP, 100 req/min):
db-sync read + node read-only. UTxO queries, protocol params.
NO transaction submission.
- Elevated (500 TRP, 1000 req/min):
Everything above + POST /v1/tx/submit (transaction broadcasting).
- Master key: Unrestricted access.
Node endpoints (/v1/address/{addr}/utxos, /v1/protocol-params, /v1/tx/submit)
return HTTP 403 for insufficient tier.
""" """
import os import os
@ -520,6 +529,42 @@ async def require_master_key(auth: dict = Depends(get_auth_context)):
return auth return auth
async def require_standard_tier(auth: dict = Depends(get_auth_context)):
"""
Require standard tier or higher for node read endpoints.
Anonymous users get db-sync only - no direct node access.
"""
if auth["tier"] == "anonymous":
raise HTTPException(
status_code=403,
detail={
"error": "tier_required",
"message": "Node access requires standard tier or higher (50+ TRP)",
"required_tier": "standard",
"current_tier": auth["tier"]
}
)
return auth
async def require_elevated_tier(auth: dict = Depends(get_auth_context)):
"""
Require elevated tier for transaction submission.
Standard tier gets read-only node access, elevated gets write.
"""
if auth["tier"] not in ("elevated", "master"):
raise HTTPException(
status_code=403,
detail={
"error": "tier_required",
"message": "Transaction submission requires elevated tier (500+ TRP)",
"required_tier": "elevated",
"current_tier": auth["tier"]
}
)
return auth
# ============ Middleware ============ # ============ Middleware ============
@app.middleware("http") @app.middleware("http")
@ -635,10 +680,13 @@ async def get_sync_status(auth: dict = Depends(get_auth_context)):
# ============ Node Integration Endpoints ============ # ============ Node Integration Endpoints ============
@app.get("/v1/address/{address}/utxos") @app.get("/v1/address/{address}/utxos")
async def get_address_utxos(address: str, auth: dict = Depends(get_auth_context)): async def get_address_utxos(address: str, auth: dict = Depends(require_standard_tier)):
""" """
Query UTxOs for an address directly from the node. Query UTxOs for an address directly from the node.
Faster than db-sync for current unspent outputs. Faster than db-sync for current unspent outputs.
Requires: standard tier (50+ TRP) or higher.
Anonymous users should use /v1/address/{address}/balance (db-sync) instead.
""" """
cache_key = f"utxos_{address}" cache_key = f"utxos_{address}"
cached = await get_cached(cache_key) cached = await get_cached(cache_key)
@ -715,20 +763,18 @@ async def get_address_utxos(address: str, auth: dict = Depends(get_auth_context)
@app.post("/v1/tx/submit") @app.post("/v1/tx/submit")
async def submit_transaction( async def submit_transaction(
request: TxSubmitRequest, request: TxSubmitRequest,
auth: dict = Depends(get_auth_context) auth: dict = Depends(require_elevated_tier)
): ):
""" """
Submit a signed transaction to the network. Submit a signed transaction to the network.
Accepts hex or base64 encoded CBOR transaction. Accepts hex or base64 encoded CBOR transaction.
Requires: elevated tier (500+ TRP) or master key.
Standard tier gets read-only node access.
""" """
# Check tx submission rate limit # Check tx submission rate limit (elevated tier still has limits)
allowed, retry_after = await check_rate_limit(auth["identifier"], auth["tier"], "tx_submit") allowed, retry_after = await check_rate_limit(auth["identifier"], auth["tier"], "tx_submit")
if not allowed: if not allowed:
if auth["tier"] == "anonymous":
raise HTTPException(
status_code=403,
detail={"error": "forbidden", "message": "Transaction submission requires an API key"}
)
raise HTTPException( raise HTTPException(
status_code=429, status_code=429,
detail={"error": "rate_limit_exceeded", "retry_after": retry_after} detail={"error": "rate_limit_exceeded", "retry_after": retry_after}
@ -808,10 +854,12 @@ async def submit_transaction(
@app.get("/v1/protocol-params") @app.get("/v1/protocol-params")
async def get_protocol_params(auth: dict = Depends(get_auth_context)): async def get_protocol_params(auth: dict = Depends(require_standard_tier)):
""" """
Get current epoch protocol parameters from the node. Get current epoch protocol parameters from the node.
Cached for 5 minutes. Cached for 5 minutes.
Requires: standard tier (50+ TRP) or higher.
""" """
global protocol_params_cache global protocol_params_cache