Security¶
Authentication¶
HTTP API: Bearer token authentication via Authorization: Bearer <token>
header.
Configured with --require-auth flag on underwrite serve or by setting
UNDERWRITE_API_TOKEN. Token comparison uses hmac.compare_digest() to
prevent timing attacks (serve.py:147).
When require_auth=True and no token is set, the server refuses to start
with a ValueError.
Authorization¶
Module: underwrite/authz.py
AccessControl implements a policy engine with allow/deny rules and
default-deny semantics.
acl = AccessControl()
acl.allow("audit", "*") # audit service may subscribe to all events
acl.deny("foreign_svc", "*") # foreign_svc is blocked
Policy Evaluation¶
- Deny rules are checked first — first match rejects.
- Allow rules are checked second — first match permits.
- If no rule matches → deny (default).
Policy File¶
Authorization rules can be loaded from a JSON file:
{"allow": [{"subject": "mechanism", "resource": "*"}],
"deny": [{"subject": "untrusted", "resource": "saga.*"}]}
Set authz.policy_file in config or UNDERWRITE_AUTHZ_POLICY_FILE.
Enforcement Points¶
Core.subscribe()— checkscheck_subscribe()before registering the handler.Core.emit()— callsassert_publish()before publishing.AccessControl.assert_verified()— verifies event signature at dispatch time (services/base.py:294-306).
Cryptographic Provenance¶
Every event emitted by a nano service is Ed25519-signed by the
service's Identity. The signed payload binds the source so a holder
of any trusted key cannot re-stamp events under another service id.
Signing (services/base.py:233-285)¶
The signing bytes are produced by Message.canonical_sign_bytes():
to_sign = f"{event.event_id}|{event.timestamp}|{event.event_type}|{event.source}|{payload_str}"
signature = identity.sign(to_sign)
The pipe-separated form binds the source into the signed bytes, and
the payload is JSON-encoded with sorted keys so the signature is stable
across dict iteration order. The signature and public key
(source_key) are attached to the Message envelope.
Verification (authz.py:155-203)¶
The receiving service reconstructs the canonical bytes and verifies:
A 5-minute clock window is enforced: events older than
--replay-window (or dated more than that far in the future) are
rejected. Set the window with AccessControl.set_replay_window(seconds);
pass 0 (or a negative value) to disable the check (not recommended).
Trust Model¶
AccessControl.trust(service_id, public_key)registers a trusted key.AccessControl.revoke_trust(service_id)removes it.AccessControl.is_trusted(service_id)checks for a registered key.verify_signature()returnsFalseif no trusted key is registered for the event source.- When
cryptographylibrary is unavailable, the import fails loudly — there is no silent insecure fallback.
Key Management¶
Service identities are persisted through the configured
SecretsManager. The runtime identity is created at startup; service
identities are created the first time Identity.create(service_id,
secrets_manager=...) is called. PEM-encoded keys are stored at
underwrite/{service_id}/private_key (or via the backend's equivalent
path) and loaded on every restart. The new
Identity.to_pem() / Identity.persist() helpers expose the private
key for external storage.
Module: underwrite/identity.py
Key Generation¶
Identity.create() generates Ed25519 keypairs. Private keys can be:
- Generated fresh (default)
- Loaded from PEM string
- Loaded from SecretsManager (Vault, AWS SM, or env vars)
Encryption at Rest¶
Private keys can be encrypted in memory using PKCS8
BestAvailableEncryption with a passphrase:
Without a passphrase, keys are stored as raw bytes (base64-encoded) in memory.
Key Rotation¶
The platform does not auto-rotate Ed25519 keys. Rotation is an operator-driven operation:
- Generate a new identity for the same service by running
Identity.create(service_id + "_v2", secrets_manager=...). - Publish the new public key out-of-band to all subscribers.
- Configure
AccessControl.set_replay_window(seconds)to a value longer than the maximum expected in-flight signature lifetime so the replay window keeps recent signatures verifiable. - Update the runtime / service to use the new key.
- Decommission the old identity once the replay window has passed.
Subscribers should accept a previous public_key for at least as
long as the replay window; AccessControl.trust() may be called
multiple times per service id to register the new key without
removing the old one.
Secrets Management¶
Module: underwrite/secrets.py
SecretsManager abstracts secret retrieval behind a SecretsBackend:
| Backend | Description |
|---|---|
EnvSecretsBackend |
Reads env vars UNDERWRITE_SECRET_<NAME> (read-only) |
VaultSecretsBackend |
HashiCorp Vault KV v2 (requires hvac) |
AwsSecretsBackend |
AWS Secrets Manager (requires boto3) |
Key path convention: underwrite/{service_id}/private_key.
PII Redaction¶
Module: underwrite/__pii.py
Field-Based Redaction¶
Keys matching known PII fields are redacted:
aadhaar, pan, ssn, tax_id, passport, driving_license,
voter_id, phone, mobile, email, account_number, ifsc,
bank_account.
Value-Based Redaction¶
String values matching these regex patterns are redacted:
- 12-digit numbers (Aadhaar-like)
- PAN-like ([A-Z]{5}[0-9]{4}[A-Z])
- SSN-like (\d{3}-\d{2}-\d{4} or 9 digits)
- Passport-like patterns
Usage¶
- AuditService redacts every event payload via
PIISanitizer.sanitize()before persisting to the ledger. - JSON log formatter redacts sensitive fields in log output.
SQL Injection Prevention¶
File: underwrite/store.py
Sqlite uses parameterized queries for all values:
Table and column names are baked into the SQL strings at module load — they are never built from caller-supplied data.
Model Integrity¶
File: underwrite/services/risk/model.py:180-196
Risk model files are verified with SHA-256 before loading:
- Expected hash from
RISK_MODEL_SHA256env var or a<model_path>.sha256sidecar file. - Mismatch raises
ValueErrorand the model is not loaded.
Joblib deserialisation (arbitrary pickle) is gated behind
UNDERWRITE_ALLOW_JOBLIB=true — disabled by default.
Rate Limiting¶
HTTP Level (serve.py)¶
Token-bucket rate limiter applied to all endpoints. Configurable via
--rate-limit (default 100 req/s). Returns 429 when exhausted.
Event Bus Level (bus.py:284-328)¶
RateLimiter implements a token-bucket per subscriber. Configurable
via bus.rate_limit in config. DistributedRateLimiter extends this
with store-backed counters for cross-process coordination.
Idempotency Guard (bus.py:376-423)¶
IdempotencyGuard prevents duplicate event processing. Bounded per
handler at max_ids_per_handler=100000. Duplicates are silently dropped.
DPDPA 2023 Compliance (India)¶
The platform is designed to support compliance with the Digital Personal Data Protection Act, 2023. Operationally, teams must configure and audit the following.
Consent Management¶
The ConsentService manages consent lifecycle per DPDPA Chapter II requirements:
- Purpose-specific consent: Each data processing purpose (KYC, credit bureau, loan servicing, collection, communication) requires separate consent.
- Validity period: Configurable via
dpdpa.consent.consent_validity_days(default 365 days). Expired consent triggersconsent.expiredevents. - Withdrawal: Borrowers may withdraw consent at any time via
consent.withdrawnevent. - Pre-check: Compliance service performs consent pre-check before initiating KYC workflows.
Data Subject Rights (DSR)¶
The DataSubjectRightsService handles DPDPA Chapter III data subject rights:
| Right | DSR Type | Implementation |
|---|---|---|
| Right to access | access |
Returns all stored personal data for the data subject |
| Right to correction | correction |
Updates inaccurate personal data |
| Right to erasure | erasure |
Purges personal data (subject to legal retention) |
| Right to data portability | portability |
Exports data in machine-readable format |
| Right to grievance redressal | grievance |
Escalates unresolved complaints to DPO |
DSR fulfillment timeline: 30 days (configurable via dpdpa.dsr.response_time_days). Grievance resolution: 15 days (configurable via dpdpa.dsr.grievance_response_days).
Data Retention¶
Configured via dpdpa config:
- General data: 8 years (data_retention_years) per IT Act record-keeping requirements
- KYC data: 5 years (kyc_retention_years) per PMLA rules
- Auto-purge: Optional (enable_auto_purge) — when enabled, expired data is automatically purged with data.purged event emission
Breach Notification¶
Per DPDPA Section 8, the platform supports breach lifecycle:
- Detection:
breach.detectedevent emitted on potential breach identification - Notification:
breach.notifiedemitted within 72 hours (configurable viadpdpa.breach_notification_hours) to the Data Protection Board and affected data subjects - Closure:
breach.closedemitted after investigation and remediation
Grievance Redressal¶
The platform tracks complaints via the grievance event flow:
- grievance.logged — complaint received from data subject
- grievance.resolved — complaint addressed within 15-day window
DPO contact information is configured via dpdpa.dsr.dpo_email and dpdpa.dsr.dpo_phone.
Data Localization¶
All data is stored in-context: - Store backend: SQLite file — keep the database on a volume attached to Indian-region compute to avoid cross-border data transfer - Audit ledger: In-process event ledger with optional export - Logging: Structured JSON logs with PII redaction before output - No external analytics: No third-party analytics or tracking SDKs
For Indian cloud deployment, deploy store and application in the same Indian region (AWS ap-south-1, Azure Central India, or GCP asia-south1).
PII Redation¶
Module: underwrite/__pii.py
In addition to the field-based and value-based redaction described above, the redactor handles Indian PII identifiers:
- Aadhaar: 12-digit numbers (masked to last 4 digits)
- PAN:
[A-Z]{5}[0-9]{4}[A-Z]format (masked to last 4 characters) - Voter ID: EPIC format
- Passport: International passport format
- Bank account: Account number + IFSC combination
- Phone / Email: Contact identifiers
Redaction is applied by AuditService before event persistence and by the JSON log formatter before output.
Full-Page PII and Data Protection Diagram¶
flowchart TD
DP["Data Principal (Borrower)"] -->|Provides consent| CS["ConsentService"]
DP -->|Exercises rights| DSRS["DataSubjectRightsService"]
CS -->|consent.recorded| Audit
DSRS -->|dsr.fulfilled| Audit
subgraph "Data Protection Layer"
CS
DSRS
BD["BreachDetector"]
GR["GrievanceTracker"]
PP["AutoPurge"]
end
BD -->|breach.detected| Audit
GR -->|grievance.logged| Audit
PP -->|data.purged| Audit
Audit -->|PII-redacted| Ledger
Security Checklist¶
- Enable authorization: Set
authz.enabled=truein config. - Set an API token:
UNDERWRITE_API_TOKENand--require-auth. - Use a persistent Sqlite path on a durable volume: the
:memory:backend is for tests. Production deployments need a SQLite file on durable storage in the Indian region. - Rotate signing keys: Generate a new
Identity.create(...)for the new rotation, update the runtime, and rely onAccessControl.set_replay_window(...)to keep recent signatures verifiable. - Configure Vault/AWS SM: Store private keys in a secrets backend, not env vars.
- Enable PII redaction: Verify
PIISanitizeris applied (audit service does this by default). - Validate model hashes: Set
RISK_MODEL_SHA256for production risk models. - Restrict joblib: Never set
UNDERWRITE_ALLOW_JOBLIB=trueunless you control the model file. - Configure consent management: Set
dpdpa.consent.required_purposesfor all data processing purposes. - Set DPO contact: Configure
dpdpa.dsr.dpo_emailfor grievance redressal. - Configure data retention: Set
dpdpa.data_retention_yearsanddpdpa.kyc_retention_yearsper compliance requirements. - Enable breach detection: Ensure
dpdpa.enable_breach_detection=true. - Deploy in Indian region: Store and compute in AWS
ap-south-1, Azure Central India, or GCPasia-south1.