ADR-024: Tenant-Scoped Plugin Credentials and BYOK Data Source Configuration
| Field | Value |
|---|---|
| Status | Draft |
| Date | 2026-04-21 |
| Relates | ADR-009, ADR-022, ADR-008a, ADR-019, ADR-020 |
1. Problem Statement
ADR-009 introduced the data provider plugin architecture with a plugin registry, declarative ontology mappings, and country-based routing. ADR-022 introduced multi-tenancy with row-level isolation. However, the current credential model stores API keys at the platform level in data_providers.credentials. This creates three problems:
Problem A: TrustRelay becomes the data broker. If TrustRelay supplies and manages API credentials for every data source, TrustRelay bears the contractual, financial, and regulatory responsibility for data usage. Each data provider (Termene.ro, KVK, NorthData, OpenSanctions) has its own terms of service, rate limits, and billing. TrustRelay cannot manage these relationships at scale across hundreds of tenants.
Problem B: Credential isolation is violated. A single set of credentials shared across tenants means one tenant's API usage affects another tenant's rate limits. Audit trails at the data provider level cannot distinguish which tenant triggered which query. If a credential is revoked (e.g., for non-payment), all tenants lose access simultaneously.
Problem C: Self-hosted customers cannot participate. Self-hosted Atlas deployments require customers to use their own data provider accounts. There is no mechanism for a tenant administrator to configure, validate, and manage their own credentials within Atlas.
The required architecture: TrustRelay provides the plugin connectors and the intelligence layer. Each customer brings their own API credentials (BYOK: Bring Your Own Key). Atlas stores, encrypts, and uses these credentials on the tenant's behalf, but never shares them across tenant boundaries.
2. Decision
2.1 Tenant-Scoped Plugin Configuration
Each tenant independently enables plugins and supplies their own credentials. The platform plugin registry (ADR-009) defines what plugins are available. The tenant plugin configuration defines which plugins are active for that tenant and how they authenticate.
The data flow:
Platform Plugin Registry (read-only for tenants)
│
▼
Tenant enables plugin → Tenant provides credentials → Atlas validates credentials
│
▼
Plugin available for tenant's investigations
│
▼
Plugin executes with tenant's credentials → Raw data stored in tenant-scoped staging
│
▼
Schema mappings float data into ontology → Risk matrix consumes ontology fields → Score
2.2 Separation of Concerns
| Layer | Owner | Responsibility |
|---|---|---|
| Plugin code (client, mapper, mapping_spec) | TrustRelay (platform) | Connector logic, API integration, data normalization |
| Plugin registry (available plugins, versions) | TrustRelay (platform) | Catalog of what plugins exist and their capabilities |
| Plugin configuration (enabled, credentials, settings) | Tenant admin | Which plugins are active, API keys, tenant-specific settings |
| Plugin execution (API calls, data ingestion) | Atlas runtime | Executes with tenant's credentials in tenant-scoped context |
| Raw data (source-native responses) | Tenant data | Stored with tenant_id, never shared across tenants |
| Schema mappings (source → ontology) | Configurable per tenant | Default platform mappings, overridable per tenant |
| Ontology fields | Tenant data | Entity profiles built from mapped source data |
| Risk scoring | Tenant configuration | Tenant's risk matrices consume ontology fields |
3. Database Schema
3.1 New Table: tenant_plugin_configurations
This table stores per-tenant plugin settings and replaces the credential storage currently in data_providers.credentials.
CREATE TABLE tenant_plugin_configurations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(100) NOT NULL,
plugin_name VARCHAR(100) NOT NULL REFERENCES data_providers(name),
enabled BOOLEAN NOT NULL DEFAULT false,
credentials_encrypted BYTEA, -- encrypted at rest
credentials_key_id VARCHAR(100), -- reference to encryption key
settings JSONB NOT NULL DEFAULT '{}', -- tenant-specific plugin settings
rate_limit_override JSONB, -- tenant can lower (not raise) platform limits
last_credential_validation TIMESTAMPTZ,
credential_status VARCHAR(20) NOT NULL DEFAULT 'unconfigured'
CHECK (credential_status IN (
'unconfigured', -- no credentials provided
'validating', -- validation in progress
'valid', -- last validation succeeded
'invalid', -- last validation failed
'expired' -- credential known to be expired
)),
credential_validation_error TEXT, -- last error message if invalid
enabled_at TIMESTAMPTZ,
enabled_by VARCHAR(200), -- user who enabled the plugin
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_tenant_plugin UNIQUE (tenant_id, plugin_name)
);
-- RLS policy (defense-in-depth, per ADR-022)
ALTER TABLE tenant_plugin_configurations ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON tenant_plugin_configurations
USING (tenant_id = current_setting('app.current_tenant_id'));
-- Index for plugin lookup during investigation
CREATE INDEX idx_tenant_plugin_enabled
ON tenant_plugin_configurations(tenant_id, plugin_name)
WHERE enabled = true;
3.2 New Table: tenant_plugin_usage
Tracks API usage per tenant per plugin for rate limiting and billing visibility.
CREATE TABLE tenant_plugin_usage (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(100) NOT NULL,
plugin_name VARCHAR(100) NOT NULL,
period_start DATE NOT NULL, -- daily granularity
api_calls_count INTEGER NOT NULL DEFAULT 0,
api_calls_succeeded INTEGER NOT NULL DEFAULT 0,
api_calls_failed INTEGER NOT NULL DEFAULT 0,
entities_fetched INTEGER NOT NULL DEFAULT 0,
last_call_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_tenant_plugin_period UNIQUE (tenant_id, plugin_name, period_start)
);
ALTER TABLE tenant_plugin_usage ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON tenant_plugin_usage
USING (tenant_id = current_setting('app.current_tenant_id'));
3.3 Migration of Existing Credentials
The data_providers.credentials column is deprecated for tenant-specific credentials. It remains as a fallback for platform-level defaults (e.g., a shared OpenSanctions key provided by TrustRelay for cloud customers). The resolution order is:
- Tenant-specific credentials from
tenant_plugin_configurations - Platform default credentials from
data_providers.credentials(ifallow_platform_fallback = truein plugin manifest) - No credentials available → plugin disabled for this tenant
4. Credential Management
4.1 Encryption at Rest
Credentials are encrypted before storage using AES-256-GCM with tenant-scoped encryption keys.
@dataclass
class PluginCredentials:
"""Decrypted credential set for a plugin."""
api_key: str | None = None
username: str | None = None
password: str | None = None
oauth_client_id: str | None = None
oauth_client_secret: str | None = None
custom_fields: dict[str, str] = field(default_factory=dict)
The encryption key hierarchy:
Platform Master Key (environment variable or secrets manager)
│
└── Tenant Key (derived: HKDF(master_key, tenant_id))
│
└── Encrypts tenant_plugin_configurations.credentials_encrypted
For self-hosted deployments, customers provide their own master key. TrustRelay never has access to self-hosted customer credentials.
For cloud deployments, the master key is stored in the infrastructure secrets manager (e.g., AWS Secrets Manager, Azure Key Vault). TrustRelay operations staff do not have access to decrypted credentials in production.
4.2 Credential Lifecycle
unconfigured ──▶ validating ──▶ valid ──▶ enabled
│ │
▼ ▼
invalid expired / invalid
│ │
▼ ▼
(user fixes) (user re-validates)
Setting credentials:
- Tenant admin enters credentials in the Plugin Configuration UI
- Credentials are encrypted immediately in the API layer (never logged, never stored in plaintext)
- Atlas runs a validation call (e.g., fetch a known test entity from the data provider)
- If validation succeeds:
credential_status = 'valid', plugin can be enabled - If validation fails:
credential_status = 'invalid', error message stored, plugin cannot be enabled
Credential rotation:
- Tenant admin enters new credentials
- Old credentials remain active until new credentials are validated
- On successful validation, old credentials are overwritten (single active credential set per plugin per tenant)
- All in-flight requests using old credentials complete normally
Credential expiry detection:
- If a plugin API call returns 401/403, mark
credential_status = 'expired' - Disable the plugin for this tenant (graceful: complete in-flight, reject new requests)
- Notify tenant admin via in-app notification
- Log the event in
tenant_plugin_usagefor audit
4.3 Credential Access at Runtime
Credentials are decrypted only at the moment of API call execution, within the tenant-scoped Temporal activity context:
async def execute_plugin_fetch(
tenant_id: str,
plugin_name: str,
registration_number: str,
country_code: str,
) -> ProviderResponse:
"""
Execute a plugin data fetch with tenant-scoped credentials.
Runs inside a Temporal activity with tenant context (ADR-022).
"""
# 1. Load tenant plugin configuration
config = await get_tenant_plugin_config(tenant_id, plugin_name)
if not config or not config.enabled:
raise PluginNotEnabledError(tenant_id, plugin_name)
# 2. Decrypt credentials (in-memory only, never persisted decrypted)
credentials = decrypt_credentials(
config.credentials_encrypted,
config.credentials_key_id,
tenant_id,
)
# 3. Check rate limits (tenant-specific)
await enforce_rate_limit(tenant_id, plugin_name, config.rate_limit_override)
# 4. Execute API call with tenant's credentials
provider = get_provider(plugin_name)
response = await provider.fetch_company_complete(
registration_number=registration_number,
country_code=country_code,
credentials=credentials,
)
# 5. Track usage
await increment_usage(tenant_id, plugin_name)
# 6. Store raw response with tenant_id (tenant-scoped via RLS)
await store_provider_response(tenant_id, response)
return response
5. Plugin Configuration UI
5.1 Plugin Marketplace View
Accessible from Settings → Data Sources (renamed from "Data Providers" for clarity).
The view shows all available plugins from the platform registry as cards:
┌─────────────────────────────────────────────────────────────┐
│ Available Data Sources │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 🇳🇱 KVK │ │ 🇷🇴 Termene │ │ 🌍 NorthData │ │
│ │ │ │ │ │ │ │
│ │ Company │ │ Company │ │ Company │ │
│ │ Registry │ │ UBO │ │ Financial │ │
│ │ │ │ Financial │ │ Person │ │
│ │ [Configure] │ │ [Configure] │ │ [Configure] │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ OpenSanctions│ │ OpenCorp │ │ More coming │ │
│ │ │ │ │ │ │ │
│ │ Sanctions │ │ Company │ │ │ │
│ │ PEP │ │ (Fallback) │ │ │ │
│ │ │ │ │ │ │ │
│ │ [Configure] │ │ [Configure] │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
5.2 Plugin Configuration Panel
When a tenant admin clicks "Configure" on a plugin:
┌─────────────────────────────────────────────────────────────┐
│ Termene.ro [Back] │
│ Romanian commercial registry with UBO data │
│ │
│ Status: ● Configured / Valid │
│ Enabled: [Toggle ON/OFF] │
│ │
│ ── Credentials ──────────────────────────────────────────── │
│ │
│ API Username: [••••••••••] [Show] │
│ API Password: [••••••••••] [Show] │
│ │
│ Last validated: 2026-04-21 14:32 UTC ✓ Valid │
│ [Validate Credentials] [Save] │
│ │
│ ── Coverage ─────────────────────────────────────────────── │
│ │
│ Country: Romania (RO) │
│ Authority tier: Primary │
│ Capabilities: Company, UBO, Financial, Court Cases │
│ │
│ ── Usage (this month) ──────────────────────────────────── │
│ │
│ API calls: 1,247 / unlimited │
│ Entities fetched: 312 │
│ Success rate: 98.7% │
│ │
│ ── Data Mapping ─────────────────────────────────────────── │
│ │
│ [View field mappings] (read-only, shows source → ontology) │
│ │
└─────────────────────────────────────────────────────────────┘
5.3 Credential Input Fields
Each plugin's manifest declares what credentials it needs. The UI renders the appropriate fields dynamically:
# In PluginManifest (extending ADR-009)
@dataclass
class CredentialField:
name: str # "api_key", "username", "password"
display_name: str # "API Key", "Username"
field_type: str # "password" | "text" | "textarea"
required: bool # True
help_text: str # "Get your API key at https://termene.ro/cont"
validation_pattern: str | None # Optional regex for client-side validation
@dataclass
class PluginManifest:
# ... existing fields from ADR-009 ...
credential_fields: list[CredentialField]
allow_platform_fallback: bool = False # True for OpenSanctions (TrustRelay provides default)
validation_test_query: dict | None = None # Known entity to test credentials against
Example for Termene.ro:
PluginManifest(
name="termene",
display_name="Termene.ro",
credential_fields=[
CredentialField(
name="username",
display_name="API Username",
field_type="text",
required=True,
help_text="Your Termene.ro account username",
),
CredentialField(
name="password",
display_name="API Password",
field_type="password",
required=True,
help_text="Your Termene.ro account password",
),
],
validation_test_query={
"endpoint": "dateFirmaSumar",
"cui": "14399840", # Known test CUI
"expected_field": "nume",
},
allow_platform_fallback=False,
# ... rest of manifest
)
6. Provider Router Enhancement
6.1 Tenant-Aware Routing
The ProviderRouter from ADR-009 is enhanced to check tenant plugin configuration before dispatching:
async def fetch_company_complete(
self,
registration_number: str,
country_code: str,
tenant_id: str,
required_capabilities: list[str] | None = None,
) -> ProviderResponse:
"""
Fetch company data composing responses from multiple authority tiers.
Only uses plugins that are enabled AND have valid credentials for this tenant.
"""
# 1. Get all providers for this country, ordered by authority tier
providers = await get_providers_for_country(country_code)
# 2. Filter to only those enabled for this tenant with valid credentials
tenant_configs = await get_tenant_plugin_configs(tenant_id)
available_providers = [
p for p in providers
if p.name in tenant_configs
and tenant_configs[p.name].enabled
and tenant_configs[p.name].credential_status == 'valid'
]
if not available_providers:
raise NoProvidersAvailableError(
tenant_id=tenant_id,
country_code=country_code,
message="No data sources configured for this country. "
"Configure data source credentials in Settings → Data Sources."
)
# 3. Execute primary, then supplementary, then fallback
# ... (existing ADR-009 composition logic, but with tenant credentials)
6.2 Graceful Degradation
When a tenant has only partial plugin coverage for a country:
| Scenario | Behaviour |
|---|---|
| Primary configured, supplementary not | Fetch from primary only. Entity profile has gaps (e.g., no financials). Surface gaps in UI as "Data source not configured" |
| Primary not configured, supplementary configured | Fetch from supplementary only. Lower trust scores on registry fields |
| No plugins configured for country | Investigation cannot proceed for this country. Clear error message directing to Settings |
| Credentials expired mid-investigation | Complete in-flight requests if possible, mark subsequent steps as failed, notify admin |
7. Rate Limiting
7.1 Per-Tenant Rate Enforcement
Each tenant's API calls are rate-limited independently based on the data provider's limits and the tenant's subscription tier with that provider:
async def enforce_rate_limit(
tenant_id: str,
plugin_name: str,
rate_limit_override: dict | None,
) -> None:
"""
Enforce rate limits using a sliding window counter in Redis.
Key pattern: rate:{tenant_id}:{plugin_name}:{window}
"""
# Platform default from plugin manifest
default_rpm = get_plugin_manifest(plugin_name).rate_limit["requests_per_minute"]
# Tenant can lower but not raise the platform limit
if rate_limit_override and "requests_per_minute" in rate_limit_override:
effective_rpm = min(rate_limit_override["requests_per_minute"], default_rpm)
else:
effective_rpm = default_rpm
# Redis sliding window check (tenant-scoped key per ADR-022)
key = f"rate:{tenant_id}:{plugin_name}:rpm"
current = await redis.incr(key)
if current == 1:
await redis.expire(key, 60)
if current > effective_rpm:
raise PluginRateLimitError(
tenant_id=tenant_id,
plugin_name=plugin_name,
retry_after_seconds=await redis.ttl(key),
)
7.2 Tenant A Cannot Affect Tenant B
Because each tenant uses their own API credentials and their own rate limit counters, Tenant A exhausting their Termene.ro quota has zero impact on Tenant B's Termene.ro access. This is a fundamental property of the BYOK model.
8. Audit and Observability
8.1 Plugin Execution Audit Trail
Every plugin execution is logged with full context for regulatory audit:
-- Extends existing data_provider_responses table
ALTER TABLE data_provider_responses
ADD COLUMN tenant_id VARCHAR(100) NOT NULL,
ADD COLUMN credential_key_id VARCHAR(100), -- which credential was used (not the credential itself)
ADD COLUMN execution_duration_ms INTEGER,
ADD COLUMN rate_limit_remaining INTEGER;
8.2 Tenant Admin Dashboard
Tenant admins see a Data Sources dashboard with:
- Per-plugin API call volume (daily/weekly/monthly)
- Success/failure rates per plugin
- Average response times
- Credential status across all configured plugins
- Data freshness indicators (when was each entity last refreshed from each source)
8.3 Platform Admin Dashboard
TrustRelay platform admins (cloud only) see aggregate metrics without accessing tenant credentials:
- Plugins enabled per tenant (count, not credentials)
- Plugin health across all tenants
- Most/least used plugins
- Error rate trends per plugin (to detect provider outages)
9. Termene.ro Plugin Manifest
As the motivating example, here is the complete manifest for the Termene.ro plugin:
PluginManifest(
name="termene",
display_name="Termene.ro",
version="1.0.0",
description="Romanian commercial registry data including company details, "
"associates, administrators, beneficial owners, financial statements, "
"ANAF debts, court cases, and insolvency filings.",
provider_type="composite", # covers multiple capability types
country_codes=["RO"],
capabilities=[
"company", # CUI, name, legal form, CAEN code, status, registration date
"person", # Associates and administrators with ownership percentages
"ownership", # Beneficial owners (beneficiari reali) from ONRC
"financials", # Balance sheets, turnover, ANAF debts
"litigation", # Court cases, insolvency filings
],
trust_level=0.93, # Official ONRC data via aggregator (not direct registry API)
credential_fields=[
CredentialField(
name="username",
display_name="API Username",
field_type="text",
required=True,
help_text="Your Termene.ro member account username",
),
CredentialField(
name="password",
display_name="API Password",
field_type="password",
required=True,
help_text="Your Termene.ro member account password",
),
],
api_base_url="https://termene.ro/api",
test_base_url=None, # No sandbox; validation uses a real lightweight call
documentation_url="https://termene.ro/documentatie-api",
rate_limit={"requests_per_minute": 60},
allow_platform_fallback=False, # Each tenant must supply their own credentials
validation_test_query={
"endpoint": "dateFirmaSumar",
"params": {"cui": "14399840", "tip": "0"},
"expected_field": "nume",
},
author="Atlas Team",
changelog=[
{"version": "1.0.0", "date": "2026-04-21", "notes": "Initial release with company, UBO, and financial data"}
],
)
9.1 Termene.ro API Endpoints Used
| Endpoint | Atlas Capability | Data Returned |
|---|---|---|
dateFirmaSumar | company | CUI, name, address, CAEN code, VAT status, fiscal status, ANAF debts, registration date |
dateFirmaDetalii | company, financials | Extended company data including financial statements and balance sheet items |
asociatiSiAdministratoriComplet | person, ownership | Name, function (associate/administrator), ownership percentage, date of birth, address, country |
asociatiSiAdministratoriSimplu | person | Name, function (simplified, lower API cost) |
beneficiari | ownership | Beneficial owners as declared to ONRC under Law 129/2019 |
9.2 Termene.ro → Atlas Ontology Mapping (Key Fields)
| Termene Field | Atlas Entity | Atlas Attribute | Trust |
|---|---|---|---|
cui | LegalEntity | registration_number | 0.98 |
nume | LegalEntity | legal_name | 0.97 |
cod_caen | LegalEntity | nace_code | 0.95 |
adresa | Address | full_address | 0.93 |
data_inregistrarii | LegalEntity | incorporation_date | 0.98 |
capital_social | LegalEntity | share_capital | 0.95 |
cifra_de_afaceri_neta | LegalEntity | net_turnover | 0.93 |
statut_TVA | LegalEntity | vat_registered | 0.97 |
statut_fiscal | LegalEntity | fiscal_status | 0.97 |
datorii_anaf | LegalEntity | tax_debts | 0.93 |
asociat .nume_prenume | Person | full_name | 0.95 |
asociat .procentaj | Relationship (OwnerOf) | ownership_percentage | 0.95 |
asociat .data_nastere | Person | date_of_birth | 0.95 |
asociat .functie | Relationship | role (associate/administrator) | 0.97 |
asociat .tara | Person | country | 0.93 |
| beneficiar fields | Person (UBO) | is_ubo, ubo_type, beneficial_interest | 0.93 |
10. Migration Path
Phase 1: Credential Infrastructure (1 sprint)
- Create
tenant_plugin_configurationstable with RLS - Create
tenant_plugin_usagetable with RLS - Implement credential encryption/decryption service
- Implement credential validation flow
- Add
credential_fieldsandallow_platform_fallbacktoPluginManifest - Migrate existing NorthData credentials to tenant-scoped configuration for existing tenants
Phase 2: Tenant-Aware Router (1 sprint)
- Enhance
ProviderRouterto check tenant plugin configurations - Implement per-tenant rate limiting with Redis
- Add
tenant_idtodata_provider_responses - Implement graceful degradation for partial plugin coverage
- Implement credential expiry detection (401/403 handling)
Phase 3: Plugin Configuration UI (1 sprint)
- Build Data Sources marketplace view (plugin cards)
- Build Plugin Configuration panel (credentials, status, usage)
- Build credential validation UX (async validation with status feedback)
- Build usage dashboard per plugin
- Add "Data source not configured" indicators in investigation results
Phase 4: Termene.ro Plugin (1 sprint)
- Implement
TermeneProviderclient with all five endpoints - Write
mapping_spec.yamlfor Termene → Atlas ontology mapping - Build test fixtures from real Termene.ro responses
- Integration tests with Termene.ro API
- End-to-end test: configure Termene credentials → search RO company → entity profile with UBO data
Total Effort: ~4 sprints
11. Security Considerations
Credential Protection
- Credentials encrypted at rest (AES-256-GCM) with tenant-derived keys
- Credentials decrypted only in-memory at execution time
- Credentials never logged (not in application logs, not in Temporal workflow history, not in error messages)
- Credentials never returned in API responses (write-only from the API perspective)
- Credential validation errors return "invalid credentials" without exposing the credential value
Tenant Isolation
- RLS on
tenant_plugin_configurationsprevents cross-tenant credential access - Tenant-scoped Redis keys prevent cross-tenant rate limit interference
- Plugin execution runs within
tenant_background_sessioncontext (ADR-022) - Platform admin dashboards show usage counts, never credentials
Self-Hosted Security
- Master encryption key provided by the customer, stored in their infrastructure
- TrustRelay has zero access to self-hosted credentials
- Customer controls backup, rotation, and destruction of encryption keys
- No credential data leaves the customer's infrastructure
12. Rejected Alternatives
12.1 TrustRelay-Managed Credentials (Data Broker Model)
TrustRelay maintains enterprise accounts with each data provider and manages credentials centrally. Rejected because:
- TrustRelay absorbs contractual and financial risk for every data provider relationship
- Scaling to hundreds of tenants across dozens of providers creates unsustainable operational burden
- Self-hosted customers cannot participate
- Violates the principle that each obliged entity should own and control their data supply chain
- Rate limit sharing across tenants creates unpredictable service quality
12.2 External Secrets Manager Only (No Database Storage)
Store all credentials exclusively in HashiCorp Vault or AWS Secrets Manager. Rejected because:
- Adds a hard infrastructure dependency that not all self-hosted customers can satisfy
- Increases latency for every plugin execution (network call to secrets manager)
- The encrypted column approach is simpler and sufficient for the current scale
- Can migrate to external secrets manager later by changing the encryption backend without changing the application layer
12.3 OAuth Proxy (Credential-Free for Tenants)
Atlas acts as an OAuth intermediary, and tenants authenticate via OAuth flows with data providers. Rejected because:
- Most compliance data providers (Termene.ro, KVK, NorthData) use API keys, not OAuth
- Would require each provider to support Atlas as an OAuth client
- Adds unnecessary complexity for a simple API key model