From 81eb035f645224e1425dc5a6d6642b4ba11b2166 Mon Sep 17 00:00:00 2001 From: Justin Bowen Date: Sat, 25 Jul 2026 20:38:11 -0500 Subject: [PATCH] feat(nhi): per-identity DNS domain allowlists enforced at DoH Manager side: machine_clients and oidc_trust_anchors now accept allowed_domains (JSON list of FQDNs or *.suffix patterns; NULL=unrestricted, []=deny all); CRUD validates hostnames, max 256 entries. Tokens issued via client_credentials and token-exchange include dns_domains claim ONLY when allowed_domains is non-NULL. DNS side: DoH query path enforces dns_domains claim with case-insensitive, trailing-dot-normalized matching (exact or wildcard suffix; *.example.com matches subdomains but NOT example.com itself). Non-match returns Status 3 (NXDOMAIN). Tokens without the claim behave exactly as today (zero regression). Metrics: dns_policy_denials counter labeled by outcome (policy_denied). Migration 010 adds allowed_domains columns (TEXT, JSON format) to both tables. Tests: manager CRUD validation, claim presence/absence, oversized list rejection; DNS matching exact/wildcard/depth/case/trailing-dot normalization, empty policy, absent claim. Both suites green. Co-Authored-By: Claude Fable 5 --- dns-server/app/main.py | 22 +- dns-server/app/services/prometheus_metrics.py | 15 ++ dns-server/app/utils/domain_policy.py | 62 +++++ dns-server/tests/test_domain_policy.py | 115 +++++++++ .../versions/010_dns_domain_allowlists.py | 33 +++ manager/backend/app/blueprints/auth.py | 26 +- .../backend/app/blueprints/machine_clients.py | 57 +++- .../app/blueprints/oidc_trust_anchors.py | 50 +++- manager/backend/app/schema.py | 2 + manager/backend/app/services/auth_service.py | 9 +- .../backend/app/utils/domain_validation.py | 77 ++++++ manager/backend/tests/test_machine_clients.py | 244 ++++++++++++++++++ 12 files changed, 698 insertions(+), 14 deletions(-) create mode 100644 dns-server/app/utils/domain_policy.py create mode 100644 dns-server/tests/test_domain_policy.py create mode 100644 manager/backend/alembic/versions/010_dns_domain_allowlists.py create mode 100644 manager/backend/app/utils/domain_validation.py diff --git a/dns-server/app/main.py b/dns-server/app/main.py index 114f4327..434834e1 100644 --- a/dns-server/app/main.py +++ b/dns-server/app/main.py @@ -8,7 +8,7 @@ from quart import Quart, request, jsonify from typing import Optional -from app.config import DNS_PORT, SYNC_INTERVAL, HEARTBEAT_INTERVAL, LOG_LEVEL +from app.config import DNS_PORT, SYNC_INTERVAL, HEARTBEAT_INTERVAL, LOG_LEVEL, JWT_PUBLIC_KEY from app.services.manager_client import ManagerClient from app.services.dns_resolver import DNSResolver from app.services.cache_manager import CacheManager @@ -17,6 +17,8 @@ from app.utils.resilience import ResilienceManager from app.services.prometheus_metrics import PrometheusMetrics, init_prometheus_metrics from app.services.http3_serving import build_serving_config +from app.utils.domain_policy import matches_policy +from app.utils.jwt_verify import verify_squawk_jwt # Configure logging logging.basicConfig( @@ -121,6 +123,24 @@ async def dns_query(): ) return jsonify({'Status': 3, 'Question': [{'name': domain, 'type': record_type}], 'Answer': []}), 200 + # Check DNS domain policy (per-identity allowlist) + if token: + payload = verify_squawk_jwt(token, JWT_PUBLIC_KEY) + if payload: + allowed_domains = payload.get('dns_domains') + if not matches_policy(domain, allowed_domains): + logger.info(f"Domain policy denial for {domain}: dns_domains={allowed_domains}") + metrics_reporter.record_policy_denial('policy_denied') + metrics_reporter.record_query( + domain=domain, + record_type=record_type, + status='policy_denied', + response_time=0.0, + cache_hit=False, + source=token or 'unknown' + ) + return jsonify({'Status': 3, 'Question': [{'name': domain, 'type': record_type}], 'Answer': []}), 200 + # Check IOC feeds if ioc_checker.is_blocked(domain): logger.warning(f"Blocked IOC domain: {domain}") diff --git a/dns-server/app/services/prometheus_metrics.py b/dns-server/app/services/prometheus_metrics.py index 238e7932..ddad8f3e 100644 --- a/dns-server/app/services/prometheus_metrics.py +++ b/dns-server/app/services/prometheus_metrics.py @@ -163,6 +163,13 @@ def _init_metrics(self): registry=self.registry, ) + self.dns_policy_denials = Counter( + "squawk_dns_policy_denials_total", + "Total DNS queries denied by domain policy", + ["outcome"], + registry=self.registry, + ) + # System Resource Metrics self.dns_memory_usage_bytes = Gauge( "squawk_dns_memory_usage_bytes", "Memory usage in bytes", @@ -252,6 +259,14 @@ def record_authentication_failure(self, failure_type: str): """Record authentication failure""" self.dns_authentication_failures.labels(failure_type=failure_type).inc() + def record_policy_denial(self, outcome: str): + """Record DNS query denied by domain policy. + + Args: + outcome: Denial reason (e.g., 'policy_denied', 'empty_policy') + """ + self.dns_policy_denials.labels(outcome=outcome).inc() + def record_upstream_query(self, upstream_server: str, response_time: float): """Record upstream DNS query timing""" self.dns_upstream_duration_seconds.labels( diff --git a/dns-server/app/utils/domain_policy.py b/dns-server/app/utils/domain_policy.py new file mode 100644 index 00000000..17df50dd --- /dev/null +++ b/dns-server/app/utils/domain_policy.py @@ -0,0 +1,62 @@ +"""DNS domain policy matching for per-identity allowlists. + +Implements case-insensitive, trailing-dot-normalized exact and wildcard suffix +matching. Wildcards (*.example.com) match any depth under the suffix but not +the suffix itself. +""" + +from typing import Optional, List + + +def normalize_domain(domain: str) -> str: + """Normalize domain: lowercase, remove trailing dot.""" + return domain.lower().rstrip('.') + + +def matches_policy(queried_name: str, allowed_domains: Optional[List[str]]) -> bool: + """ + Check if queried domain name matches policy allowlist. + + Args: + queried_name: The DNS query name (e.g., "example.com", "sub.example.com") + allowed_domains: List of allowed FQDNs or *.suffix wildcards, or None (unrestricted) + Empty list denies all. + + Returns: + True if queried_name is allowed, False otherwise. + + Matching rules (case-insensitive, trailing-dot normalized): + - None: unrestricted (returns True) + - []: deny all (returns False) + - Exact match: "example.com" matches exactly "example.com" (not "sub.example.com") + - Wildcard suffix: "*.example.com" matches "sub.example.com", "a.b.example.com" + but NOT "example.com" itself + """ + if allowed_domains is None: + # NULL = unrestricted + return True + + if not allowed_domains: + # Empty list = deny all + return False + + normalized_query = normalize_domain(queried_name) + + for entry in allowed_domains: + normalized_entry = normalize_domain(entry) + + # Exact match + if normalized_query == normalized_entry: + return True + + # Wildcard suffix: *.example.com matches a.example.com, b.c.example.com, etc. + if normalized_entry.startswith('*.'): + suffix = normalized_entry[2:] # Remove '*.' + # Must match a subdomain: ends with .suffix but not equal to suffix + if normalized_query.endswith('.' + suffix) or normalized_query == suffix: + # Check: if it's exactly the suffix, wildcard doesn't match + if normalized_query == suffix: + continue + return True + + return False diff --git a/dns-server/tests/test_domain_policy.py b/dns-server/tests/test_domain_policy.py new file mode 100644 index 00000000..559c8da8 --- /dev/null +++ b/dns-server/tests/test_domain_policy.py @@ -0,0 +1,115 @@ +"""Tests for DNS domain policy matching.""" + +import pytest +from app.utils.domain_policy import matches_policy, normalize_domain + + +class TestDomainPolicyMatching: + """Test domain policy matching logic.""" + + def test_normalize_domain_lowercase(self): + """Normalize domain to lowercase.""" + assert normalize_domain("Example.COM") == "example.com" + assert normalize_domain("SUB.EXAMPLE.COM") == "sub.example.com" + + def test_normalize_domain_trailing_dot(self): + """Normalize domain removes trailing dot.""" + assert normalize_domain("example.com.") == "example.com" + assert normalize_domain("sub.example.com.") == "sub.example.com" + + def test_unrestricted_none(self): + """NULL allowed_domains = unrestricted (return True).""" + assert matches_policy("example.com", None) is True + assert matches_policy("anything.com", None) is True + assert matches_policy("sub.sub.example.com", None) is True + + def test_deny_all_empty_list(self): + """Empty list = deny all (return False).""" + assert matches_policy("example.com", []) is False + assert matches_policy("sub.example.com", []) is False + + def test_exact_match(self): + """Exact match: exact FQDN only.""" + allowed = ["example.com"] + assert matches_policy("example.com", allowed) is True + assert matches_policy("sub.example.com", allowed) is False + assert matches_policy("example.org", allowed) is False + + def test_exact_match_case_insensitive(self): + """Exact match is case-insensitive.""" + allowed = ["Example.COM"] + assert matches_policy("example.com", allowed) is True + assert matches_policy("EXAMPLE.COM", allowed) is True + assert matches_policy("ExAmPlE.cOm", allowed) is True + + def test_exact_match_trailing_dot_normalized(self): + """Exact match normalizes trailing dots.""" + allowed = ["example.com."] + assert matches_policy("example.com", allowed) is True + assert matches_policy("example.com.", allowed) is True + + def test_wildcard_suffix_match(self): + """Wildcard suffix *.example.com matches subdomains.""" + allowed = ["*.example.com"] + assert matches_policy("sub.example.com", allowed) is True + assert matches_policy("a.b.example.com", allowed) is True + assert matches_policy("a.b.c.d.example.com", allowed) is True + + def test_wildcard_suffix_does_not_match_base(self): + """Wildcard *.example.com does NOT match example.com itself.""" + allowed = ["*.example.com"] + assert matches_policy("example.com", allowed) is False + + def test_wildcard_suffix_case_insensitive(self): + """Wildcard suffix matching is case-insensitive.""" + allowed = ["*.Example.COM"] + assert matches_policy("sub.example.com", allowed) is True + assert matches_policy("SUB.EXAMPLE.COM", allowed) is True + + def test_wildcard_suffix_trailing_dot_normalized(self): + """Wildcard suffix normalizes trailing dots.""" + allowed = ["*.example.com."] + assert matches_policy("sub.example.com", allowed) is True + assert matches_policy("sub.example.com.", allowed) is True + + def test_multiple_entries(self): + """Multiple entries in allowlist.""" + allowed = ["example.com", "*.test.org", "specific.other.net"] + assert matches_policy("example.com", allowed) is True + assert matches_policy("sub.test.org", allowed) is True + assert matches_policy("specific.other.net", allowed) is True + assert matches_policy("other.org", allowed) is False + + def test_deep_wildcard_match(self): + """Wildcard matches arbitrarily deep subdomains.""" + allowed = ["*.example.com"] + assert matches_policy("a.b.c.d.e.f.example.com", allowed) is True + + def test_no_match_similar_domain(self): + """Similar domain names don't match.""" + allowed = ["example.com"] + assert matches_policy("example.co", allowed) is False + assert matches_policy("exampleX.com", allowed) is False + assert matches_policy("example-com", allowed) is False + + def test_no_match_parent_domain(self): + """Parent domain doesn't match wildcard for subdomain.""" + allowed = ["*.example.com"] + assert matches_policy("com", allowed) is False + assert matches_policy("example.com", allowed) is False + # But exact match of parent should not match the wildcard + + def test_mixed_exact_and_wildcard(self): + """Mix of exact and wildcard entries.""" + allowed = ["example.com", "*.test.org", "static.value.net"] + assert matches_policy("example.com", allowed) is True + assert matches_policy("sub.example.com", allowed) is False + assert matches_policy("api.test.org", allowed) is True + assert matches_policy("static.value.net", allowed) is True + assert matches_policy("static.value.net.co", allowed) is False + + def test_whitespace_in_domain_not_normalized(self): + """Domains with whitespace are not valid (not normalized).""" + allowed = ["example.com"] + # Whitespace is not normalized away + assert matches_policy("example. com", allowed) is False diff --git a/manager/backend/alembic/versions/010_dns_domain_allowlists.py b/manager/backend/alembic/versions/010_dns_domain_allowlists.py new file mode 100644 index 00000000..2e963182 --- /dev/null +++ b/manager/backend/alembic/versions/010_dns_domain_allowlists.py @@ -0,0 +1,33 @@ +"""Add DNS domain allowlists — per-identity DNS domain allowlists enforced at DoH. + +Revision ID: 010_dns_domain_allowlists +Revises: 009_oidc_trust_anchors +""" +from alembic import op +from sqlalchemy import Column, Text + +revision = "010_dns_domain_allowlists" +down_revision = "009_oidc_trust_anchors" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Add allowed_domains column to machine_client and oidc_trust_anchor tables.""" + # Add to machine_client + op.add_column( + 'machine_client', + Column('allowed_domains', Text, nullable=True) + ) + + # Add to oidc_trust_anchor + op.add_column( + 'oidc_trust_anchor', + Column('allowed_domains', Text, nullable=True) + ) + + +def downgrade() -> None: + """Remove allowed_domains columns.""" + op.drop_column('machine_client', 'allowed_domains') + op.drop_column('oidc_trust_anchor', 'allowed_domains') diff --git a/manager/backend/app/blueprints/auth.py b/manager/backend/app/blueprints/auth.py index 5855b632..fda4a5a0 100644 --- a/manager/backend/app/blueprints/auth.py +++ b/manager/backend/app/blueprints/auth.py @@ -7,6 +7,7 @@ from app.services.auth_service import AuthService from app.middleware.auth import token_required, get_current_user from app.utils.decorators import validate_json, audit_log +import json auth_bp = Blueprint('auth', __name__) @@ -315,11 +316,23 @@ def token(): else: granted_scopes = client['scopes'] + # Fetch allowed_domains from DB and parse JSON + db = current_app.db + mc_record = db((db.machine_client.client_id == client['client_id']) & + (db.machine_client.active == True)).select().first() + allowed_domains = None + if mc_record and mc_record.allowed_domains: + try: + allowed_domains = json.loads(mc_record.allowed_domains) + except (json.JSONDecodeError, TypeError): + allowed_domains = None + # Issue token access_token = AuthService.create_machine_access_token( client_id=client['client_id'], tenant=client['tenant'], - granted_scopes=granted_scopes + granted_scopes=granted_scopes, + allowed_domains=allowed_domains ) # Update last_used_at @@ -409,11 +422,20 @@ def token(): else: granted_scopes = trust_anchor.allowed_scopes + # Fetch allowed_domains from trust anchor and parse JSON + allowed_domains = None + if trust_anchor.allowed_domains: + try: + allowed_domains = json.loads(trust_anchor.allowed_domains) + except (json.JSONDecodeError, TypeError): + allowed_domains = None + # Issue token (with machine marker, tenant from anchor) access_token = AuthService.create_machine_access_token( client_id=f"oidc:{subject}", # Synthetic client_id for logging tenant=trust_anchor.tenant, - granted_scopes=granted_scopes + granted_scopes=granted_scopes, + allowed_domains=allowed_domains ) ttl_config = current_app.config.get('MACHINE_ACCESS_TOKEN_EXPIRES') diff --git a/manager/backend/app/blueprints/machine_clients.py b/manager/backend/app/blueprints/machine_clients.py index ff26c954..2c7f67b8 100644 --- a/manager/backend/app/blueprints/machine_clients.py +++ b/manager/backend/app/blueprints/machine_clients.py @@ -9,8 +9,10 @@ from app.services.auth_service import AuthService from app.services.scopes import SUPERADMIN_SCOPE from app.utils.decorators import validate_json, audit_log +from app.utils.domain_validation import validate_allowed_domains from datetime import datetime import logging +import json machine_clients_bp = Blueprint('machine_clients', __name__) log = logging.getLogger(__name__) @@ -62,19 +64,26 @@ def list_machine_clients(): limitby=(offset, offset + limit) ) - return jsonify([ - { + result = [] + for c in clients: + allowed_domains = None + if c.allowed_domains: + try: + allowed_domains = json.loads(c.allowed_domains) + except (json.JSONDecodeError, TypeError): + allowed_domains = None + result.append({ 'id': c.id, 'client_id': c.client_id, 'tenant': c.tenant, 'scopes': c.scopes, + 'allowed_domains': allowed_domains, 'description': c.description, 'active': c.active, 'created_at': c.created_at.isoformat(), 'last_used_at': c.last_used_at.isoformat() if c.last_used_at else None, - } - for c in clients - ]), 200 + }) + return jsonify(result), 200 @machine_clients_bp.route('/api/v1/machine-clients', methods=['POST']) @@ -109,10 +118,16 @@ def create_machine_client(): scopes = data.get('scopes', '').strip() description = data.get('description', '').strip() tenant = data.get('tenant', 'default').strip() + allowed_domains = data.get('allowed_domains') # None or list if not scopes: return jsonify({'error': 'scopes required and must be non-empty'}), 400 + # Validate allowed_domains if provided + is_valid, error_msg = validate_allowed_domains(allowed_domains) + if not is_valid: + return jsonify({'error': error_msg}), 400 + # Validate scopes exist in the system from app.services.scopes import ROLE_SCOPES, _READ_SCOPES all_valid_scopes = set() @@ -133,6 +148,14 @@ def create_machine_client(): registered_scopes=scopes ) + # Update allowed_domains if provided + if allowed_domains is not None: + db = current_app.db + db(db.machine_client.client_id == client_id).update( + allowed_domains=json.dumps(allowed_domains) + ) + db.commit() + # Log with client_id only (never secret) log.info(f"Machine client created: client_id={client_id}, scopes={scopes}", extra={'audit': True}) @@ -143,6 +166,7 @@ def create_machine_client(): 'client_secret': secret_plaintext, 'tenant': tenant, 'scopes': scopes, + 'allowed_domains': allowed_domains, 'description': description, 'active': True, 'created_at': datetime.utcnow().isoformat() @@ -174,11 +198,19 @@ def get_machine_client(client_id: int): if not client: return jsonify({'error': 'Machine client not found'}), 404 + allowed_domains = None + if client.allowed_domains: + try: + allowed_domains = json.loads(client.allowed_domains) + except (json.JSONDecodeError, TypeError): + allowed_domains = None + return jsonify({ 'id': client.id, 'client_id': client.client_id, 'tenant': client.tenant, 'scopes': client.scopes, + 'allowed_domains': allowed_domains, 'description': client.description, 'active': client.active, 'created_at': client.created_at.isoformat(), @@ -236,6 +268,13 @@ def update_machine_client(client_id: int): if 'active' in data: update_fields['active'] = bool(data['active']) + if 'allowed_domains' in data: + allowed_domains = data['allowed_domains'] + is_valid, error_msg = validate_allowed_domains(allowed_domains) + if not is_valid: + return jsonify({'error': error_msg}), 400 + update_fields['allowed_domains'] = json.dumps(allowed_domains) if allowed_domains is not None else None + if update_fields: db(db.machine_client.id == client_id).update(**update_fields) db.commit() @@ -247,11 +286,19 @@ def update_machine_client(client_id: int): log.info(f"Machine client updated: client_id={client.client_id}", extra={'audit': True}) + allowed_domains = None + if client.allowed_domains: + try: + allowed_domains = json.loads(client.allowed_domains) + except (json.JSONDecodeError, TypeError): + allowed_domains = None + return jsonify({ 'id': client.id, 'client_id': client.client_id, 'tenant': client.tenant, 'scopes': client.scopes, + 'allowed_domains': allowed_domains, 'description': client.description, 'active': client.active, 'created_at': client.created_at.isoformat(), diff --git a/manager/backend/app/blueprints/oidc_trust_anchors.py b/manager/backend/app/blueprints/oidc_trust_anchors.py index 331b58ad..e9270b95 100644 --- a/manager/backend/app/blueprints/oidc_trust_anchors.py +++ b/manager/backend/app/blueprints/oidc_trust_anchors.py @@ -7,8 +7,10 @@ from app.middleware.auth import token_required, get_current_user from app.middleware.rbac import requires_system_admin from app.utils.decorators import validate_json, audit_log +from app.utils.domain_validation import validate_allowed_domains from datetime import datetime import logging +import json oidc_trust_anchors_bp = Blueprint('oidc_trust_anchors', __name__) log = logging.getLogger(__name__) @@ -60,20 +62,27 @@ def list_oidc_trust_anchors(): limitby=(offset, offset + limit) ) - return jsonify([ - { + result = [] + for a in anchors: + allowed_domains = None + if a.allowed_domains: + try: + allowed_domains = json.loads(a.allowed_domains) + except (json.JSONDecodeError, TypeError): + allowed_domains = None + result.append({ 'id': a.id, 'issuer': a.issuer, 'audience': a.audience, 'tenant': a.tenant, 'allowed_scopes': a.allowed_scopes, + 'allowed_domains': allowed_domains, 'subject_pattern': a.subject_pattern, 'active': a.active, 'created_at': a.created_at.isoformat(), 'updated_at': a.updated_at.isoformat() if a.updated_at else None, - } - for a in anchors - ]), 200 + }) + return jsonify(result), 200 @oidc_trust_anchors_bp.route('/api/v1/oidc-trust-anchors', methods=['POST']) @@ -116,6 +125,7 @@ def create_oidc_trust_anchor(): static_jwks_pem = data.get('static_jwks_pem', '').strip() or None allowed_scopes = data.get('allowed_scopes', '').strip() subject_pattern = data.get('subject_pattern', '').strip() or None + allowed_domains = data.get('allowed_domains') # None or list tenant = data.get('tenant', 'default').strip() if not issuer or not audience or not allowed_scopes: @@ -124,6 +134,11 @@ def create_oidc_trust_anchor(): if not jwks_url and not static_jwks_pem: return jsonify({'error': 'Either jwks_url or static_jwks_pem required'}), 400 + # Validate allowed_domains if provided + is_valid, error_msg = validate_allowed_domains(allowed_domains) + if not is_valid: + return jsonify({'error': error_msg}), 400 + # Validate scopes exist in the system from app.services.scopes import ROLE_SCOPES all_valid_scopes = set() @@ -152,6 +167,7 @@ def create_oidc_trust_anchor(): static_jwks_pem=static_jwks_pem, tenant=tenant, allowed_scopes=allowed_scopes, + allowed_domains=json.dumps(allowed_domains) if allowed_domains is not None else None, subject_pattern=subject_pattern, active=True, created_at=datetime.utcnow() @@ -166,6 +182,7 @@ def create_oidc_trust_anchor(): 'audience': audience, 'tenant': tenant, 'allowed_scopes': allowed_scopes, + 'allowed_domains': allowed_domains, 'subject_pattern': subject_pattern, 'active': True, 'created_at': datetime.utcnow().isoformat() @@ -183,12 +200,20 @@ def get_oidc_trust_anchor(anchor_id: int): if not anchor: return jsonify({'error': 'OIDC trust anchor not found'}), 404 + allowed_domains = None + if anchor.allowed_domains: + try: + allowed_domains = json.loads(anchor.allowed_domains) + except (json.JSONDecodeError, TypeError): + allowed_domains = None + return jsonify({ 'id': anchor.id, 'issuer': anchor.issuer, 'audience': anchor.audience, 'tenant': anchor.tenant, 'allowed_scopes': anchor.allowed_scopes, + 'allowed_domains': allowed_domains, 'subject_pattern': anchor.subject_pattern, 'active': anchor.active, 'created_at': anchor.created_at.isoformat(), @@ -233,6 +258,13 @@ def update_oidc_trust_anchor(anchor_id: int): if 'active' in data: update_fields['active'] = bool(data['active']) + if 'allowed_domains' in data: + allowed_domains = data['allowed_domains'] + is_valid, error_msg = validate_allowed_domains(allowed_domains) + if not is_valid: + return jsonify({'error': error_msg}), 400 + update_fields['allowed_domains'] = json.dumps(allowed_domains) if allowed_domains is not None else None + update_fields['updated_at'] = datetime.utcnow() if update_fields: @@ -246,12 +278,20 @@ def update_oidc_trust_anchor(anchor_id: int): log.info(f"OIDC trust anchor updated: issuer={anchor.issuer}", extra={'audit': True}) + allowed_domains = None + if anchor.allowed_domains: + try: + allowed_domains = json.loads(anchor.allowed_domains) + except (json.JSONDecodeError, TypeError): + allowed_domains = None + return jsonify({ 'id': anchor.id, 'issuer': anchor.issuer, 'audience': anchor.audience, 'tenant': anchor.tenant, 'allowed_scopes': anchor.allowed_scopes, + 'allowed_domains': allowed_domains, 'subject_pattern': anchor.subject_pattern, 'active': anchor.active, 'created_at': anchor.created_at.isoformat(), diff --git a/manager/backend/app/schema.py b/manager/backend/app/schema.py index d3e218e5..331c9a5c 100644 --- a/manager/backend/app/schema.py +++ b/manager/backend/app/schema.py @@ -592,6 +592,7 @@ Column("client_secret_hash", String(255), nullable=False), Column("tenant", String(255), nullable=False, server_default="default"), Column("scopes", String(1024), nullable=False), # Space-separated scope list + Column("allowed_domains", Text), # JSON list of allowed FQDNs or *.suffix wildcards; NULL=unrestricted Column("description", Text), Column("active", Boolean, nullable=False, server_default="1"), Column("created_at", DateTime, nullable=False, server_default=func.now()), @@ -613,6 +614,7 @@ Column("tenant", String(255), nullable=False, server_default="default"), Column("allowed_scopes", String(1024), nullable=False), # Space-separated Column("subject_pattern", String(255)), # Glob pattern for subject claim + Column("allowed_domains", Text), # JSON list of allowed FQDNs or *.suffix wildcards; NULL=unrestricted Column("active", Boolean, nullable=False, server_default="1"), Column("created_at", DateTime, nullable=False, server_default=func.now()), Column("updated_at", DateTime, onupdate=func.now()), diff --git a/manager/backend/app/services/auth_service.py b/manager/backend/app/services/auth_service.py index 940bfd51..ad222df7 100644 --- a/manager/backend/app/services/auth_service.py +++ b/manager/backend/app/services/auth_service.py @@ -386,7 +386,8 @@ def verify_machine_client(client_id: str, client_secret: str) -> Optional[Dict]: @staticmethod def create_machine_access_token(client_id: str, tenant: str, granted_scopes: str, - expires_in: Optional[int] = None) -> str: + expires_in: Optional[int] = None, + allowed_domains: Optional[List[str]] = None) -> str: """ Create a short-lived JWT access token for a machine client. @@ -395,6 +396,7 @@ def create_machine_access_token(client_id: str, tenant: str, tenant: Tenant ID granted_scopes: Space-separated scope grant (validated subset) expires_in: Token TTL in seconds (default 15 min from config) + allowed_domains: List of allowed DNS domains (or None for unrestricted) Returns: Signed JWT access token @@ -419,6 +421,11 @@ def create_machine_access_token(client_id: str, tenant: str, 'exp': datetime.utcnow() + ttl, 'iat': datetime.utcnow() } + + # Include dns_domains claim ONLY if allowed_domains is non-NULL + if allowed_domains is not None: + payload['dns_domains'] = allowed_domains + return jwt.encode( payload, current_app.config['JWT_PRIVATE_KEY'], diff --git a/manager/backend/app/utils/domain_validation.py b/manager/backend/app/utils/domain_validation.py new file mode 100644 index 00000000..1d3a6cdd --- /dev/null +++ b/manager/backend/app/utils/domain_validation.py @@ -0,0 +1,77 @@ +"""Domain allowlist validation for machine_clients and oidc_trust_anchors. + +Validates hostnames and wildcard patterns (*.example.com), enforces constraints +(lowercase, max 256 entries, valid syntax). +""" + +import re +from typing import List, Optional + + +def validate_hostname(hostname: str) -> bool: + """ + Validate a single hostname or wildcard pattern. + + Args: + hostname: FQDN (e.g., "example.com") or wildcard (e.g., "*.example.com") + + Returns: + True if valid, False otherwise + """ + # Allow *.suffix or bare hostname + if hostname.startswith('*.'): + base = hostname[2:] + else: + base = hostname + + # Validate FQDN: labels 1-63 chars (alphanumeric, hyphen), hyphen not at start/end + # Total length <= 253 + if len(base) > 253 or len(base) == 0: + return False + + labels = base.split('.') + if not labels or len(labels) == 0: + return False + + for label in labels: + if len(label) == 0 or len(label) > 63: + return False + # Label must start/end with alphanumeric, middle can have hyphens + if not re.match(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$', label): + return False + + return True + + +def validate_allowed_domains(domains: Optional[List[str]]) -> tuple[bool, Optional[str]]: + """ + Validate a list of allowed domain patterns. + + Args: + domains: List of hostnames/wildcards, or None + + Returns: + Tuple (is_valid, error_message): + - (True, None) if valid or None + - (False, error_string) if invalid + """ + if domains is None: + # NULL = unrestricted (valid) + return True, None + + if not isinstance(domains, list): + return False, "allowed_domains must be a list of hostnames or None" + + if len(domains) > 256: + return False, f"Maximum 256 allowed domains, got {len(domains)}" + + for domain in domains: + if not isinstance(domain, str): + return False, f"Each domain must be a string, got {type(domain).__name__}" + + domain = domain.lower() # Enforce lowercase + + if not validate_hostname(domain): + return False, f"Invalid domain pattern: {domain}" + + return True, None diff --git a/manager/backend/tests/test_machine_clients.py b/manager/backend/tests/test_machine_clients.py index 9c8344fd..a0c306c8 100644 --- a/manager/backend/tests/test_machine_clients.py +++ b/manager/backend/tests/test_machine_clients.py @@ -753,3 +753,247 @@ def test_token_exchange_subject_pattern_mismatch(self, app, client, jwt_keypair) assert response.status_code == 401 data = response.get_json() assert data['error'] == 'invalid_grant' + + +class TestDomainAllowlists: + """Test DNS domain allowlists for machine clients and OIDC anchors.""" + + def test_create_machine_client_with_allowed_domains(self, app, client, jwt_token_factory): + """Create machine client with domain allowlist.""" + auth_token = jwt_token_factory(global_role='SystemAdmin') + + response = client.post( + '/api/v1/machine-clients', + json={ + 'scopes': 'users:read', + 'description': 'Restricted client', + 'allowed_domains': ['example.com', '*.test.org'], + 'tenant': 'default' + }, + headers={'Authorization': f'Bearer {auth_token}'} + ) + + assert response.status_code == 201 + data = response.get_json() + assert data['allowed_domains'] == ['example.com', '*.test.org'] + + def test_create_machine_client_allowed_domains_null(self, app, client, jwt_token_factory): + """Create machine client with NULL allowed_domains (unrestricted).""" + auth_token = jwt_token_factory(global_role='SystemAdmin') + + response = client.post( + '/api/v1/machine-clients', + json={ + 'scopes': 'users:read', + 'description': 'Unrestricted client', + 'allowed_domains': None, + 'tenant': 'default' + }, + headers={'Authorization': f'Bearer {auth_token}'} + ) + + assert response.status_code == 201 + data = response.get_json() + assert data['allowed_domains'] is None + + def test_create_machine_client_invalid_domain(self, app, client, jwt_token_factory): + """Create fails with invalid domain pattern.""" + auth_token = jwt_token_factory(global_role='SystemAdmin') + + response = client.post( + '/api/v1/machine-clients', + json={ + 'scopes': 'users:read', + 'description': 'Bad domains', + 'allowed_domains': ['invalid..domain'], + 'tenant': 'default' + }, + headers={'Authorization': f'Bearer {auth_token}'} + ) + + assert response.status_code == 400 + data = response.get_json() + assert 'Invalid domain pattern' in data['error'] + + def test_create_machine_client_oversized_domain_list(self, app, client, jwt_token_factory): + """Create fails with >256 domains.""" + auth_token = jwt_token_factory(global_role='SystemAdmin') + + domains = [f'domain{i}.com' for i in range(257)] + + response = client.post( + '/api/v1/machine-clients', + json={ + 'scopes': 'users:read', + 'description': 'Too many domains', + 'allowed_domains': domains, + 'tenant': 'default' + }, + headers={'Authorization': f'Bearer {auth_token}'} + ) + + assert response.status_code == 400 + data = response.get_json() + assert '256' in data['error'] + + def test_update_machine_client_allowed_domains(self, app, client, jwt_token_factory): + """Update machine client with new allowed_domains.""" + auth_token = jwt_token_factory(global_role='SystemAdmin') + + with app.app_context(): + db = app.db + client_id, _, _ = AuthService.create_machine_client( + 'default', 'Test', 'users:read' + ) + record = db(db.machine_client.client_id == client_id).select().first() + record_id = record.id + + response = client.patch( + f'/api/v1/machine-clients/{record_id}', + json={'allowed_domains': ['example.com', '*.api.example.com']}, + headers={'Authorization': f'Bearer {auth_token}'} + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['allowed_domains'] == ['example.com', '*.api.example.com'] + + def test_machine_client_token_includes_dns_domains_claim(self, app, client, jwt_keypair): + """Machine client token includes dns_domains claim when set.""" + with app.app_context(): + db = app.db + client_id, secret, _ = AuthService.create_machine_client( + 'default', 'Test', 'users:read' + ) + # Update with allowed_domains + import json + db(db.machine_client.client_id == client_id).update( + allowed_domains=json.dumps(['example.com', '*.test.org']) + ) + db.commit() + + auth_header = base64.b64encode(f'{client_id}:{secret}'.encode()).decode() + + response = client.post( + '/api/v1/auth/token', + data={'grant_type': 'client_credentials'}, + headers={'Authorization': f'Basic {auth_header}'} + ) + + assert response.status_code == 200 + data = response.get_json() + access_token = data['access_token'] + + payload = jwt.decode( + access_token, + jwt_keypair['public'], + algorithms=['ES256'], + audience='squawk' + ) + + assert 'dns_domains' in payload + assert payload['dns_domains'] == ['example.com', '*.test.org'] + + def test_machine_client_token_no_dns_domains_claim_when_null(self, app, client, jwt_keypair): + """Machine client token has NO dns_domains claim when allowed_domains is NULL.""" + with app.app_context(): + client_id, secret, _ = AuthService.create_machine_client( + 'default', 'Test', 'users:read' + ) + + auth_header = base64.b64encode(f'{client_id}:{secret}'.encode()).decode() + + response = client.post( + '/api/v1/auth/token', + data={'grant_type': 'client_credentials'}, + headers={'Authorization': f'Basic {auth_header}'} + ) + + assert response.status_code == 200 + data = response.get_json() + access_token = data['access_token'] + + payload = jwt.decode( + access_token, + jwt_keypair['public'], + algorithms=['ES256'], + audience='squawk' + ) + + # dns_domains claim should NOT be present + assert 'dns_domains' not in payload + + def test_oidc_trust_anchor_with_allowed_domains(self, app, client, jwt_token_factory): + """Create OIDC trust anchor with allowed_domains.""" + auth_token = jwt_token_factory(global_role='SystemAdmin') + + response = client.post( + '/api/v1/oidc-trust-anchors', + json={ + 'issuer': 'https://k8s.example.com', + 'audience': 'squawk-api', + 'static_jwks_pem': '-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----', + 'allowed_scopes': 'users:read', + 'allowed_domains': ['k8s.local', '*.internal.example.com'], + 'subject_pattern': 'system:serviceaccount:*:*' + }, + headers={'Authorization': f'Bearer {auth_token}'} + ) + + assert response.status_code == 201 + data = response.get_json() + assert data['allowed_domains'] == ['k8s.local', '*.internal.example.com'] + + def test_oidc_trust_anchor_token_includes_dns_domains(self, app, client, jwt_keypair): + """OIDC token exchange includes dns_domains claim.""" + with app.app_context(): + db = app.db + import json + db.oidc_trust_anchor.insert( + issuer='https://external-oidc.example.com', + audience='squawk-api', + static_jwks_pem=jwt_keypair['public'], + tenant='default', + allowed_scopes='users:read', + allowed_domains=json.dumps(['k8s.internal', '*.test.local']), + subject_pattern='system:serviceaccount:*:*', + active=True + ) + db.commit() + + # Create external token + external_payload = { + 'iss': 'https://external-oidc.example.com', + 'aud': 'squawk-api', + 'sub': 'system:serviceaccount:default:my-sa', + 'exp': datetime.utcnow() + timedelta(hours=1), + 'iat': datetime.utcnow() + } + external_token = jwt.encode( + external_payload, + jwt_keypair['private'], + algorithm='ES256' + ) + + response = client.post( + '/api/v1/auth/token', + data={ + 'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange', + 'subject_token': external_token, + 'subject_token_type': 'urn:ietf:params:oauth:token-type:jwt' + } + ) + + assert response.status_code == 200 + data = response.get_json() + access_token = data['access_token'] + + payload = jwt.decode( + access_token, + jwt_keypair['public'], + algorithms=['ES256'], + audience='squawk' + ) + + assert 'dns_domains' in payload + assert payload['dns_domains'] == ['k8s.internal', '*.test.local']