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.

Step 1

The Supply Chain Hierarchy

The EUDR supply chain is modeled as a strict hierarchy. Each enum member carries a numeric level that determines visibility.

Python
class UserRole(str, Enum):
    TRADER   = "trader"      # Level 4
    REFINERY = "refinery"    # Level 3
    MILLS    = "mills"       # Level 2
    ESTATE   = "estate"      # Level 1
    ADMIN    = "admin"       # Level 99
What this means
Traders (level 4) sit at the top of the commercial chain. They see everything below them — refineries, mills, and estates.
Estates (level 1) are at the bottom. They can only see their own data.
Admin (level 99) bypasses hierarchy entirely and has full visibility across all roles and organizations.
The can_view_role() method simply checks: is my level ≥ target level? No per-role permission table needed.
Expected result: A role-based visibility system where higher-level actors automatically see data from all lower levels.
Step 2

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.

Python
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,
            }
What this means
FREE tier: Basic RADD radar alerts, up to 5 plots, basic PDF reports, 60 requests per hour. No GLAD optical alerts, no satellite imagery in reports, no batch processing.
ENTERPRISE tier: Full GLAD + RADD alerts, unlimited plots, enhanced PDF with satellite imagery, batch processing, 300 requests per hour, alert subscriptions.
Upgrading a user is just a database field change — no code deployment, no separate app.
Expected result: Each user's feature set is determined by their tier, returned as a dictionary that endpoint guards can check.
Step 3

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.

Python
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)
What this means
High-risk countries (+20): Brazil, Indonesia, DR Congo, Peru, Colombia, Bolivia, Venezuela, Malaysia, and others with high deforestation rates. Plots here start with a significant risk penalty.
Standard-risk (0): Countries like Ecuador, Guatemala, Honduras — moderate deforestation context, no adjustment.
Low-risk (-20): US, Canada, Australia, NZ, EU countries — strong forest governance earns a negative adjustment (lowers risk).
Unknown (+10): Countries not in any list get a cautious positive adjustment because absence of data is itself a risk signal.
Expected result: A numeric risk adjustment based on the plot's country, fed into the composite risk score.
Step 4

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:

Python (simplified)
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))
What this means
Start at 0. The score begins neutral and accumulates risk from independent signals.
Country adjustment (-20 to +20): Where the plot is located sets the baseline. A plot in Brazil starts at 20; one in Germany starts at -20.
Forest loss (+10): If Hansen data shows more than 5% canopy loss within the plot, add 10 points.
Post-cutoff alert (+25): Any GLAD or RADD alert detected after 31 December 2020 adds a severe penalty. This is the single biggest individual factor.
Alert area factor (up to +25): Proportional to how much of the plot is affected. A small clearing in a large plot scores less than a large clearing in a small plot.
Data uncertainty (+5): Always added as a baseline margin for satellite measurement error and model uncertainty.
Clamp to 0–100: Low-risk countries with no alerts can reach negative values — clamped to 0. Worst-case stacking can exceed 100 — clamped to 100.
Expected result: A numeric risk score between 0 and 100 that summarizes all risk signals into a single comparable value.
Step 5

The Compliance Decision

The final compliance threshold check uses three independent triggers. If any one of them fires, the plot is marked NON_COMPLIANT:

Python (simplified)
# 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
What this means
Trigger 1 — Forest loss > 2%: More than 2% of the plot's canopy has been lost. This is a direct physical measurement from Hansen satellite data.
Trigger 2 — Risk score > 70: The composite risk score (from Step 4) exceeds the threshold. This captures country risk, alert severity, and data quality in a single check.
Trigger 3 — Post-cutoff alert: Any deforestation alert (GLAD or RADD) dated after 31 December 2020. This is the hard legal line from the EUDR.
A plot must clear all three checks to be COMPLIANT. Failing even one is enough for NON_COMPLIANT.
Expected result: A definitive compliance verdict — COMPLIANT, NON_COMPLIANT, or NEEDS_REVIEW — stored in the database and included in reports.

Access Control Tasks

How to check if a user can access a feature

  1. Call user.has_feature("glad_alerts") on the authenticated user object. This delegates to the user's subscription tier.
  2. Internally, has_feature() first checks if the user's role is ADMIN. If so, it always returns True — admins bypass all feature gates.
  3. For non-admin users, it calls SubscriptionTier.get_features() and looks up the requested feature key in the returned dictionary.
  4. Use this in endpoint guards: if not user.has_feature("batch_processing"): raise HTTPException(403).
  5. 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

  1. Open the SubscriptionTier enum in the models and add a new member, e.g., PROFESSIONAL = "professional".
  2. In the get_features() method, add an elif self == SubscriptionTier.PROFESSIONAL branch with the feature dictionary for the new tier.
  3. Define all feature keys with their values — the new tier can mix and match capabilities between FREE and ENTERPRISE.
  4. Update the get_features() default/fallback branch if needed to ensure unknown tiers fail safely.
  5. No database migration is required — the subscription_tier column stores a string value. Simply update the user's record to the new tier name.

How to change risk score weights

  1. 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).
  2. Change the relevant constant to adjust sensitivity. For example, lowering COMPLIANCE_RISK_SCORE_THRESHOLD from 70 to 60 makes the system more aggressive at flagging non-compliance.
  3. No code changes are needed beyond the configuration values — the risk calculation formula reads from these constants at runtime.
  4. 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

  1. Open forest_analyzer_with_alerts.py and locate the EU_COUNTRY_RISK_CLASSIFICATION dictionary.
  2. Find the appropriate risk level list: high_risk, standard_risk, or low_risk.
  3. Add the country's ISO 3166-1 alpha-2 code (two uppercase letters, e.g., "TZ" for Tanzania) to the chosen list.
  4. The change takes effect immediately on the next analysis — no database migration or restart required beyond reloading the module.
  5. 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.

One system, many deployments

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

🔀
One codebase, two experiences

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

RoleLevelCan View
ADMIN99All roles
TRADER4trader + refinery + mills + estate
REFINERY3refinery + mills + estate
MILLS2mills + estate
ESTATE1estate only

Subscription Features

FeatureFreeEnterprise
RADD alertsYesYes
GLAD alertsNoYes
Max plots5Unlimited
Enhanced PDF (satellite)NoYes
Batch processingNoYes
API rate limit60/hr300/hr
Alert subscriptionsNoYes

Risk Score Components

FactorPointsCondition
Country (high)+20BR, ID, CD, PE, CO, BO, VE, MY, PG, KH, LA, MM, GH, CI, NG, CM, AR, PY
Country (standard)0EC, GU, HN, NI, CR, PA and others
Country (low)-20US, CA, AU, NZ, EU countries
Country (unknown)+10Not in any classification list
Post-cutoff alert+25GLAD or RADD alert after 2020-12-31
Alert area factorup to +25(alert_area / plot_area) × 5, capped at 25
Forest loss+10Loss > 5% of plot canopy
Data uncertainty+5Always added as baseline margin

High-Risk Countries

CodeCountry
BRBrazil
IDIndonesia
CDDR Congo
PEPeru
COColombia
BOBolivia
VEVenezuela
MYMalaysia
PGPapua New Guinea
KHCambodia
LALaos
MMMyanmar
GHGhana
CICôte d'Ivoire
NGNigeria
CMCameroon
ARArgentina
PYParaguay