Access Control & Business Logic
Roles, tiers, risk scoring, and compliance rules.
Walk Through the Access Control System
This module traces the access control and business logic layers that determine who can see what and how compliance verdicts are computed. Each step maps to real code in the backend.
The Supply Chain Hierarchy
The EUDR supply chain is modeled as a strict hierarchy. Each enum member carries a numeric level that determines visibility.
class UserRole(str, Enum):
TRADER = "trader" # Level 4
REFINERY = "refinery" # Level 3
MILLS = "mills" # Level 2
ESTATE = "estate" # Level 1
ADMIN = "admin" # Level 99
can_view_role() method simply checks: is my level ≥ target level? No per-role permission table needed.Subscription Tiers
Feature access is governed by subscription tiers, not roles. The SubscriptionTier.get_features() method returns a dictionary of capabilities for the user's tier. This acts as a feature flag system.
class SubscriptionTier(str, Enum):
FREE = "free"
ENTERPRISE = "enterprise"
def get_features(self):
if self == SubscriptionTier.FREE:
return {
"radd_alerts": True,
"glad_alerts": False,
"max_plots": 5,
"enhanced_pdf": False,
"batch_processing": False,
"rate_limit": 60,
}
else: # ENTERPRISE
return {
"radd_alerts": True,
"glad_alerts": True,
"max_plots": 999999,
"enhanced_pdf": True,
"batch_processing": True,
"rate_limit": 300,
}
Country Risk Classification
The due diligence requirements under Article 29 depend on the source country's risk classification. The system maps ISO country codes to risk levels with a corresponding score adjustment.
def get_country_risk_level(country_code: str):
code = country_code.upper()
if code in high_risk: # BR, ID, CD...
return ('high', 20)
elif code in standard_risk:
return ('standard', 0)
elif code in low_risk:
return ('low', -20)
else:
return ('unknown', 10)
The Risk Score Formula
The risk score is a composite value from 0 to 100. It is built additively from independent factors, then clamped to the valid range. Here is how each component contributes:
risk_score = 0
# 1. Country risk adjustment
risk_score += country_adjustment # -20 to +20
# 2. Forest loss factor
if forest_loss > 5:
risk_score += 10
# 3. Post-cutoff alert penalty
if any_alert_after_cutoff:
risk_score += 25
# 4. Alert area factor
area_ratio = alert_area / plot_area
risk_score += min(area_ratio * 5, 25)
# 5. Data uncertainty baseline
risk_score += 5
# Clamp to valid range
risk_score = max(0, min(100, risk_score))
The Compliance Decision
The final compliance threshold check uses three independent triggers. If any one of them fires, the plot is marked NON_COMPLIANT:
# Three independent triggers
trigger_1 = forest_loss > 2.0 # %
trigger_2 = risk_score > 70
trigger_3 = has_post_cutoff_alert
if trigger_1 or trigger_2 or trigger_3:
status = ComplianceStatus.NON_COMPLIANT
elif borderline_conditions:
status = ComplianceStatus.NEEDS_REVIEW
else:
status = ComplianceStatus.COMPLIANT
Access Control Tasks
How to check if a user can access a feature
- Call
user.has_feature("glad_alerts")on the authenticated user object. This delegates to the user's subscription tier. - Internally,
has_feature()first checks if the user's role isADMIN. If so, it always returnsTrue— admins bypass all feature gates. - For non-admin users, it calls
SubscriptionTier.get_features()and looks up the requested feature key in the returned dictionary. - Use this in endpoint guards:
if not user.has_feature("batch_processing"): raise HTTPException(403). - The feature key must match exactly — available keys are:
radd_alerts,glad_alerts,max_plots,enhanced_pdf,batch_processing,rate_limit,alert_subscriptions.
How to add a new subscription tier
- Open the
SubscriptionTierenum in the models and add a new member, e.g.,PROFESSIONAL = "professional". - In the
get_features()method, add anelif self == SubscriptionTier.PROFESSIONALbranch with the feature dictionary for the new tier. - Define all feature keys with their values — the new tier can mix and match capabilities between FREE and ENTERPRISE.
- Update the
get_features()default/fallback branch if needed to ensure unknown tiers fail safely. - No database migration is required — the
subscription_tiercolumn stores a string value. Simply update the user's record to the new tier name.
How to change risk score weights
- All risk score weights are defined as configuration constants. The key values are:
COMPLIANCE_FOREST_LOSS_THRESHOLD(default: 2%),COMPLIANCE_RISK_SCORE_THRESHOLD(default: 70),RISK_COUNTRY_HIGH(default: +20),RISK_ALERT_POST_CUTOFF(default: +25). - Change the relevant constant to adjust sensitivity. For example, lowering
COMPLIANCE_RISK_SCORE_THRESHOLDfrom 70 to 60 makes the system more aggressive at flagging non-compliance. - No code changes are needed beyond the configuration values — the risk calculation formula reads from these constants at runtime.
- After changing thresholds, re-run any analyses that need to reflect the new settings. Existing stored results will not automatically update.
How to add a new country to the risk list
- Open
forest_analyzer_with_alerts.pyand locate theEU_COUNTRY_RISK_CLASSIFICATIONdictionary. - Find the appropriate risk level list:
high_risk,standard_risk, orlow_risk. - Add the country's ISO 3166-1 alpha-2 code (two uppercase letters, e.g.,
"TZ"for Tanzania) to the chosen list. - The change takes effect immediately on the next analysis — no database migration or restart required beyond reloading the module.
- Verify by running an analysis for a plot in that country and checking that the country risk adjustment matches the expected value in the results.
Design Reasoning
Why Configurable Thresholds?
Article 29 of the EUDR does not prescribe an exact risk methodology. It establishes the benchmarking framework but leaves the specific implementation to operators. This is intentional — different operators have fundamentally different risk appetites.
A small cocoa trader sourcing from three West African estates has very different sensitivity needs than a multinational palm oil refinery processing thousands of plots across Southeast Asia. The cocoa trader might want aggressive thresholds (lower risk score cutoff, stricter forest loss limit) because each plot is critical to their supply chain. The refinery might need standard thresholds to avoid overwhelming their review team with false positives. By keeping all weights in configuration, each deployment can be tuned without touching a single line of analysis code.
The Hierarchy Pattern
The can_view_role() method compares hierarchy levels rather than maintaining an explicit permission matrix. The check is simply: is the viewer's level greater than or equal to the target's level?
This is elegant because adding a new role — say, WAREHOUSE at level 1.5 — just means assigning it a level number. You do not need to update every permission check across the codebase. The hierarchy math handles it automatically.
Think of it like military ranks. A Colonel automatically outranks all Lieutenants, Captains, and Majors without needing a specific rule for each pairing. You only need to know where each rank sits in the chain of command. The same principle applies here: a Trader (level 4) automatically sees Refinery (3), Mills (2), and Estate (1) data without any explicit per-role rules.
Feature Flags vs Separate Apps
Instead of maintaining two separate applications (a "free" version and an "enterprise" version), the system uses a single codebase with feature flag checks at each boundary. Every endpoint that serves tier-gated content calls user.has_feature() before including premium data in the response.
This means bug fixes apply to all users simultaneously. Security patches do not need to be backported between codebases. And upgrading a user from FREE to ENTERPRISE is a single database field change — no redeployment, no migration, no downtime. The user's next API call instantly reflects their new capabilities.
Access Control Reference
Role Hierarchy
| Role | Level | Can View |
|---|---|---|
ADMIN | 99 | All roles |
TRADER | 4 | trader + refinery + mills + estate |
REFINERY | 3 | refinery + mills + estate |
MILLS | 2 | mills + estate |
ESTATE | 1 | estate only |
Subscription Features
| Feature | Free | Enterprise |
|---|---|---|
| RADD alerts | Yes | Yes |
| GLAD alerts | No | Yes |
| Max plots | 5 | Unlimited |
| Enhanced PDF (satellite) | No | Yes |
| Batch processing | No | Yes |
| API rate limit | 60/hr | 300/hr |
| Alert subscriptions | No | Yes |
Risk Score Components
| Factor | Points | Condition |
|---|---|---|
| Country (high) | +20 | BR, ID, CD, PE, CO, BO, VE, MY, PG, KH, LA, MM, GH, CI, NG, CM, AR, PY |
| Country (standard) | 0 | EC, GU, HN, NI, CR, PA and others |
| Country (low) | -20 | US, CA, AU, NZ, EU countries |
| Country (unknown) | +10 | Not in any classification list |
| Post-cutoff alert | +25 | GLAD or RADD alert after 2020-12-31 |
| Alert area factor | up to +25 | (alert_area / plot_area) × 5, capped at 25 |
| Forest loss | +10 | Loss > 5% of plot canopy |
| Data uncertainty | +5 | Always added as baseline margin |
High-Risk Countries
| Code | Country |
|---|---|
BR | Brazil |
ID | Indonesia |
CD | DR Congo |
PE | Peru |
CO | Colombia |
BO | Bolivia |
VE | Venezuela |
MY | Malaysia |
PG | Papua New Guinea |
KH | Cambodia |
LA | Laos |
MM | Myanmar |
GH | Ghana |
CI | Côte d'Ivoire |
NG | Nigeria |
CM | Cameroon |
AR | Argentina |
PY | Paraguay |