# DPDPA OPA Policy Bundle
# Implements purpose-scoping, detectability mitigation, retention enforcement,
# and Section 16 cross-border transfer controls as policy-as-code.
#
# Deploy with Envoy external authorisation, Kong plugin, or stand-alone
# OPA sidecar. Evaluate via: opa eval -d . -i input.json 'data.dpdpa'
#
# Legal anchors:
#   Section 6(1)   — purpose limitation
#   Section 8(1)   — processor accountability
#   Section 8(5)   — reasonable security safeguards
#   Section 16     — cross-border transfer restrictions
#   Rule 6(1)(a)   — encryption requirement
#   Rule 6(1)(b)   — access controls
#   Rule 6(1)(c)   — activity monitoring / logging
#   Rule 6(1)(f)   — Data Processing Agreements with Processors

package dpdpa

import rego.v1

# ===========================================================================
# Policy 1: Purpose-Scoped Access Control
# STRIDE: Elevation of Privilege
# LINDDUN: Linkability, Non-compliance
# DPDPA: Section 6(1), Section 8(1)-(2), Rule 6(1)(b)
# ===========================================================================

default access_allowed := false

access_allowed if {
    # Active consent exists for this exact purpose and principal
    some consent in input.consent_events
    consent.data_principal_id == input.resource.data_principal_id
    consent.purpose            == input.request.purpose
    consent.status             == "active"
    time.now_ns()              <  consent.expires_at_ns

    # Caller is a service authorised under the Data Processing Agreement
    input.caller.service_id in data.dpa_authorised_services[input.request.purpose]

    # Requested fields are a strict subset of the purpose's minimum-necessary set
    requested := {f | f := input.request.fields[_]}
    allowed   := {f | f := data.purpose_field_scope[input.request.purpose][_]}
    count(requested - allowed) == 0
}

access_violation contains msg if {
    not access_allowed
    msg := sprintf(
        "dpdpa.access: DENIED. caller=%s purpose=%s principal=%s — "
        "Violation of DPDPA S.6(1) purpose limitation and S.8(1) accountability. "
        "Refer Rule 6(1)(b) access controls.",
        [input.caller.service_id, input.request.purpose, input.resource.data_principal_id],
    )
}

# Produce a structured finding for SIEM/audit ingestion
access_finding := {
    "policy": "dpdpa_purpose_scope",
    "allowed": access_allowed,
    "violations": access_violation,
    "dpdpa_sections": ["S.6(1)", "S.8(1)", "S.8(2)", "Rule 6(1)(b)"],
    "linddun": ["Linkability", "Non-compliance"],
    "stride": ["Elevation of Privilege"],
    "timestamp": time.now_ns(),
}

# ===========================================================================
# Policy 2: Detectability Mitigation (API Response Normalisation)
# LINDDUN: Detectability
# DPDPA: Section 8(5)
#
# Signals that the API handler MUST return a constant-shape response
# regardless of whether the resource (account, phone number) exists.
# Enforcement is in the application layer (see consent_api/auth.py);
# this policy documents the invariant and can gate IaC / API schema changes.
# ===========================================================================

default detectability_safe := false

detectability_safe if {
    # API spec must declare that 404 is never returned on identity-lookup endpoints
    not "404" in input.api_spec.endpoints[input.request.path].responses
    # Response body schema must be identical for success and "not found" paths
    input.api_spec.endpoints[input.request.path].constant_response == true
}

detectability_violation contains msg if {
    not detectability_safe
    input.request.path in data.identity_lookup_endpoints
    msg := sprintf(
        "dpdpa.detectability: endpoint '%s' may leak membership via status code or timing. "
        "LINDDUN Detectability threat. Refer DPDPA S.8(5) reasonable safeguards.",
        [input.request.path],
    )
}

# ===========================================================================
# Policy 3: Retention Enforcement
# LINDDUN: Non-compliance
# DPDPA: Section 8(7), Rule 8 (Rule 8(3) / Rule 6(1)(e): keep at least 1 year first)
#
# Evaluated at query time against data store metadata. A store that has
# records older than its declared retention period fails this policy.
# ===========================================================================

default retention_compliant := false

retention_compliant if {
    # The data store has a retention_days value set
    input.store.retention_days > 0
    # The oldest record in the store is within the retention window
    oldest_age_days := (time.now_ns() - input.store.oldest_record_ns) / 86400000000000
    oldest_age_days <= input.store.retention_days
    # No records are flagged as past-purpose but not yet erased
    count([r | r := input.store.records[_]; r.purpose_complete == true; r.erased == false]) == 0
}

retention_violation contains msg if {
    not retention_compliant
    msg := sprintf(
        "dpdpa.retention: store '%s' has records beyond retention window (%d days) "
        "or unerased post-purpose records. "
        "Violation of DPDPA S.8(7) erasure obligation (periods: Rule 8). "
        "LINDDUN Non-compliance threat.",
        [input.store.name, input.store.retention_days],
    )
}

# ===========================================================================
# Policy 4: Cross-Border Transfer Control
# LINDDUN: Non-compliance
# DPDPA: Section 16
#
# Section 16 is a NEGATIVE-LIST model: personal data may be transferred to
# any country UNLESS the Central Government restricts it by notification.
# Default is ALLOW; block only when MeitY publishes a restricted-country list.
#
# "The Central Government may, by notification, restrict the transfer of
#  personal data by a Data Fiduciary for processing to such country or
#  territory outside India as may be so notified." — DPDPA 2023, Section 16(1)
#
# As of April 2026, MeitY has not published any restricted-country list.
# Until it does, Section 16 blocks no destination. Still check stricter
# sectoral laws (S.16(2)), any Rule 15 order on making data available to a
# foreign State, and SDF localisation of specified data (Rule 13(4)).
# ===========================================================================

default transfer_allowed := true

transfer_allowed := false if {
    # Destination is on MeitY's Section 16 restricted-country notification
    input.transfer.destination_country in data.blocked_countries
}

transfer_violation contains msg if {
    not transfer_allowed
    msg := sprintf(
        "dpdpa.transfer: data transfer to '%s' not permitted. "
        "Destination is on MeitY's Section 16 restricted-country notification. "
        "LINDDUN Non-compliance threat.",
        [input.transfer.destination_country],
    )
}

# Blocked countries data reference (populate from MeitY Section 16 notification)
# This is the operator's responsibility — update data.blocked_countries
# when MeitY issues a restriction notification under Section 16.
# Until then, the default (allow) applies — no countries are currently blocked.
# Example:
# data.blocked_countries := []   # update when MeitY Section 16 notification is issued

# ===========================================================================
# Policy 5: Encryption-at-Rest Enforcement
# STRIDE: Information Disclosure
# LINDDUN: Disclosure of Information
# DPDPA: Section 8(5), Rule 6(1)(a)
#
# Evaluated against Terraform plan or Kubernetes manifest via conftest.
# ===========================================================================

default encryption_compliant := false

# AWS: RDS, DynamoDB, S3, EBS must have encryption enabled
encryption_compliant if {
    input.resource.provider == "aws"
    input.resource.type in {"aws_db_instance", "aws_dynamodb_table", "aws_s3_bucket", "aws_ebs_volume"}
    input.resource.config.encrypted == true
}

# Azure: Storage accounts and SQL databases must have encryption enabled
encryption_compliant if {
    input.resource.provider == "azure"
    input.resource.type in {"azurerm_storage_account", "azurerm_sql_database"}
    input.resource.config.enable_https_traffic_only == true
}

# GCP: Cloud SQL and GCS must use customer-managed encryption keys
encryption_compliant if {
    input.resource.provider == "gcp"
    input.resource.type in {"google_sql_database_instance", "google_storage_bucket"}
    count(input.resource.config.encryption_key_name) > 0
}

encryption_violation contains msg if {
    not encryption_compliant
    msg := sprintf(
        "dpdpa.encryption: resource '%s' (%s/%s) does not have encryption at rest enabled. "
        "Violation of DPDPA S.8(5) and Rule 6(1)(a). "
        "STRIDE Information Disclosure and LINDDUN Disclosure of Information threats.",
        [input.resource.name, input.resource.provider, input.resource.type],
    )
}

# ===========================================================================
# Aggregated findings (use this rule in CI/CD gate evaluation)
# ===========================================================================

all_violations := access_violation | detectability_violation | retention_violation | transfer_violation | encryption_violation

dpdpa_compliant if {
    count(all_violations) == 0
}

dpdpa_report := {
    "compliant": count(all_violations) == 0,
    "violation_count": count(all_violations),
    "violations": all_violations,
    "evaluated_at": time.now_ns(),
}
