Skip to content

feat(nhi): per-identity DNS domain allowlists enforced at DoH - #71

Merged
PenguinzTech merged 3 commits into
v2.1.xfrom
feature/agent-dns-policy
Jul 27, 2026
Merged

feat(nhi): per-identity DNS domain allowlists enforced at DoH#71
PenguinzTech merged 3 commits into
v2.1.xfrom
feature/agent-dns-policy

Conversation

@PenguinzTech

Copy link
Copy Markdown
Contributor

Per-identity DNS egress control for non-human identities — the agentic-security differentiator on top of #63. Machine clients and OIDC trust anchors gain allowed_domains (NULL = unrestricted, [] = deny all, else exact FQDNs / *.suffix wildcards, validated, max 256). Tokens from both grants carry a dns_domains claim only when a list is set; the DoH data plane enforces it after zone checks with a pure matching helper (case-insensitive, trailing-dot normalized, wildcard matches any depth but not the bare suffix) — non-match returns NXDOMAIN consistent with existing selective-router denials. Tokens without the claim behave exactly as today (legacy regression-tested). Denial counter labeled by outcome only — no domains or identities in metrics.

Tests: 37 manager + 17 dns-server green (9 new CRUD/claim tests, 17 matching cases).


Stack note: Part 2 of the NHI stack — depends on #63 (feature/nhi-machine-tokens); auto-retargets as the stack merges.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@PenguinzTech PenguinzTech self-assigned this Jul 26, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @PenguinzTech, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@PenguinzTech
PenguinzTech changed the base branch from feature/nhi-machine-tokens to v2.1.x July 27, 2026 01:06
PenguinzTech and others added 2 commits July 26, 2026 21:04
- dns-server/app/main.py: combined imports -- this branch's
  domain_policy/jwt_verify additions plus already-merged #65's
  rate-limit config vars and RateLimiter import. Also removed a
  redundant local re-import of verify_squawk_jwt/JWT_PUBLIC_KEY
  (leftover from #65's original code) that was shadowing the combined
  module-level import and tripping flake8's F401 check.
- dns-server/app/services/prometheus_metrics.py: combined two
  independent new methods (record_policy_denial from this branch,
  record_rate_limited_query from #65) on the same metrics class --
  their backing Counter definitions survived the auto-merge intact.
- manager/backend/app/services/auth_service.py: auto-merged cleanly
  but silently dropped the `List` import from typing while THIS
  branch's new allowed_domains: Optional[List[str]] parameter still
  uses it -- classic silent-auto-merge risk. Restored.

Full manager suite: 264/264 passing. dns-server: 112/112 real tests
passing (8 rate-limiter test errors are a pre-existing bug already
fixed in #77, not yet in this branch's v2.1.x snapshot -- will
re-verify after pulling latest v2.1.x).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@PenguinzTech
PenguinzTech merged commit 693758a into v2.1.x Jul 27, 2026
7 of 10 checks passed
PenguinzTech added a commit that referenced this pull request Jul 27, 2026
- app/blueprints/auth.py: both grant handlers (client_credentials and
  token-exchange) needed to combine THIS branch's dpop_jkt binding
  with #71's already-merged allowed_domains DB-fetch, both passed to
  create_machine_access_token.
- app/services/auth_service.py: create_machine_access_token's
  signature/docstring/body combined to accept and apply both dpop_jkt
  (RFC 9449 cnf claim) and allowed_domains (dns_domains claim).
- app/schema.py + tests/test_schema.py: dpop_replay table (this
  branch) combined with audit_event (#61, already merged) -- this
  branch predates that merge so git saw it as new.
- alembic: two real chain issues, both from this branch predating
  later merges:
  1. 011_dpop_replays.py had NO revision/down_revision identifiers at
     all (a genuine bug -- alembic could never have discovered this
     migration). Added revision="011_dpop_replays",
     down_revision="010_dns_domain_allowlists" (010 is #71's migration,
     merged after this branch was authored; the file's stale docstring
     said "Depends on: 009").
  2. 008_add_mfa_fields (re-chained onto 009 in an earlier standalone
     v2.1.x fix, before #71 existed) and 010_dns_domain_allowlists both
     forked from 009, creating two heads. Re-chained
     008_add_mfa_fields -> 011_dpop_replays so the graph is linear
     again with 008_add_mfa_fields as the single head.

Full manager suite: 293/293 passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PenguinzTech added a commit that referenced this pull request Jul 27, 2026
- .gitleaks.toml: both branches independently created this file
  (add/add conflict). Kept origin/v2.1.x's version -- accumulated and
  already validated across #69/#71/#73/#77's resolutions.
- .pre-commit-config.yaml: same flake8/hadolint divergence resolved
  identically to prior merges this session (single .flake8 source of
  truth + flake8-bugbear; hadolint v2.14.0).
- app/schema.py: auth_user gained sso_provider/sso_subject (this
  branch) alongside external_id (#73 SCIM, already merged) -- both
  independent new columns, combined.
- app/services/scopes.py: sso:write/sso:admin (this branch) combined
  with audit:read (already merged, SystemAdmin-only).
- tests/test_schema.py: expected-tables set now covers sso_providers/
  sso_login_attempts alongside scim_tokens/machine_client/
  oidc_trust_anchor/dpop_replay/audit_event (all already merged).
- alembic: SSO's own chain (008_add_mfa_fields -> 009_add_sso_providers
  -> 010_add_sso_login_attempts -> 011_allow_null_password_hash) and
  SCIM's chain (008_add_mfa_fields -> 010_add_scim_provisioning) both
  forked from the same parent, authored independently off
  feature/mfa-totp. Re-chained 009_add_sso_providers to depend on
  010_add_scim_provisioning (SCIM's already-merged migration) instead,
  restoring a single linear head.

Full manager suite: 339/339 passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant