feat: scope Tailscale ACL grants by role instead of *:*:* #79

Merged
forgejo_admin merged 1 commit from 78-feat-tailscale-acl-tightening-replace-wi into main 2026-03-15 04:58:16 +00:00

Summary

  • Replaces the permissive src=* dst=* ip=* grant in tailscale_acl.this with role-scoped access
  • Admins retain full access, k8s nodes get only inter-node and admin-device communication
  • Empty group:developers stub ready for future onboarding (Forgejo + Woodpecker web UIs on 443)

Changes

  • terraform/main.tf -- replaced single *:*:* grant with 4 scoped grants:
    • autogroup:admin -> *:* (full access -- SSH, kubectl, all web UIs)
    • tag:k8s -> tag:k8s on * (inter-node cluster communication)
    • tag:k8s -> autogroup:admin on * (webhook callbacks, etc.)
    • group:developers -> tag:k8s on 443 (future: Forgejo + Woodpecker web UIs only)
  • Added groups block with empty group:developers stub
  • SSH, nodeAttrs, and tagOwners blocks are unchanged

tofu plan Output

  # tailscale_acl.this will be updated in-place
  ~ resource "tailscale_acl" "this" {
      ~ acl                        = jsonencode(
          ~ {
              ~ grants    = [
                  ~ {
                      ~ src = [
                          - "*",
                          + "autogroup:admin",
                        ]
                        # (2 unchanged attributes hidden)
                    },
                  + {
                      + dst = [
                          + "tag:k8s",
                        ]
                      + ip  = [
                          + "*",
                        ]
                      + src = [
                          + "tag:k8s",
                        ]
                    },
                  + {
                      + dst = [
                          + "autogroup:admin",
                        ]
                      + ip  = [
                          + "*",
                        ]
                      + src = [
                          + "tag:k8s",
                        ]
                    },
                  + {
                      + dst = [
                          + "tag:k8s",
                        ]
                      + ip  = [
                          + "443",
                        ]
                      + src = [
                          + "group:developers",
                        ]
                    },
                ]
              + groups    = {
                  + "group:developers" = []
                }
                # (3 unchanged attributes hidden)
            }
        )
        id                         = "acl"
        # (2 unchanged attributes hidden)
    }

Plan: 0 to add, 3 to change, 0 to destroy.

Note: The 2 other changes (helm_release.kube_prometheus_stack metadata drift, kubernetes_secret_v1.dora_exporter) are pre-existing state drift unrelated to this PR.

tofu fmt and tofu validate both pass clean.

Test Plan

  • tofu validate passes
  • tofu fmt produces no changes
  • tofu plan -lock=false shows only the ACL grant change (plus pre-existing drift)
  • After apply: verify admin devices can still reach all services
  • After apply: verify k8s funnels still serve traffic
  • Safety net: Tailscale admin console has full ACL history for instant revert

Review Checklist

  • Passed automated review-fix loop
  • No secrets committed
  • No unnecessary file changes
  • Commit messages are descriptive
  • Closes #78
  • plan-pal-e-platform -- Phase 8b (Tailscale ACL Tightening)
## Summary - Replaces the permissive `src=* dst=* ip=*` grant in `tailscale_acl.this` with role-scoped access - Admins retain full access, k8s nodes get only inter-node and admin-device communication - Empty `group:developers` stub ready for future onboarding (Forgejo + Woodpecker web UIs on 443) ## Changes - `terraform/main.tf` -- replaced single `*:*:*` grant with 4 scoped grants: - `autogroup:admin` -> `*:*` (full access -- SSH, kubectl, all web UIs) - `tag:k8s` -> `tag:k8s` on `*` (inter-node cluster communication) - `tag:k8s` -> `autogroup:admin` on `*` (webhook callbacks, etc.) - `group:developers` -> `tag:k8s` on `443` (future: Forgejo + Woodpecker web UIs only) - Added `groups` block with empty `group:developers` stub - SSH, nodeAttrs, and tagOwners blocks are unchanged ## tofu plan Output ``` # tailscale_acl.this will be updated in-place ~ resource "tailscale_acl" "this" { ~ acl = jsonencode( ~ { ~ grants = [ ~ { ~ src = [ - "*", + "autogroup:admin", ] # (2 unchanged attributes hidden) }, + { + dst = [ + "tag:k8s", ] + ip = [ + "*", ] + src = [ + "tag:k8s", ] }, + { + dst = [ + "autogroup:admin", ] + ip = [ + "*", ] + src = [ + "tag:k8s", ] }, + { + dst = [ + "tag:k8s", ] + ip = [ + "443", ] + src = [ + "group:developers", ] }, ] + groups = { + "group:developers" = [] } # (3 unchanged attributes hidden) } ) id = "acl" # (2 unchanged attributes hidden) } Plan: 0 to add, 3 to change, 0 to destroy. ``` Note: The 2 other changes (`helm_release.kube_prometheus_stack` metadata drift, `kubernetes_secret_v1.dora_exporter`) are pre-existing state drift unrelated to this PR. `tofu fmt` and `tofu validate` both pass clean. ## Test Plan - [x] `tofu validate` passes - [x] `tofu fmt` produces no changes - [x] `tofu plan -lock=false` shows only the ACL grant change (plus pre-existing drift) - [ ] After apply: verify admin devices can still reach all services - [ ] After apply: verify k8s funnels still serve traffic - Safety net: Tailscale admin console has full ACL history for instant revert ## Review Checklist - [x] Passed automated review-fix loop - [x] No secrets committed - [x] No unnecessary file changes - [x] Commit messages are descriptive ## Related - Closes #78 - `plan-pal-e-platform` -- Phase 8b (Tailscale ACL Tightening)
feat: scope Tailscale ACL grants by role instead of *:*:*
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
ci/woodpecker/pull_request_closed/woodpecker Pipeline was successful
77f10a2f10
Replace the permissive *:*:* grant with role-based access:
- autogroup:admin retains full *:* access (SSH, kubectl, all UIs)
- tag:k8s scoped to inter-node and admin-device communication
- group:developers stub for future Forgejo/Woodpecker-only access (443)

SSH, nodeAttrs, and tagOwners blocks are unchanged.

Closes #78

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author
Owner

Self-Review

Reviewed diff against all acceptance criteria from issue #78. No blockers found.

Checklist:

  • *:*:* grant removed
  • autogroup:admin retains full *:* access
  • tag:k8s scoped to inter-node + admin-device only
  • group:developers stub with 443-only access to tag:k8s
  • SSH, nodeAttrs, tagOwners blocks unchanged
  • tofu fmt clean
  • tofu validate passes
  • tofu plan shows only ACL change (plus 2 pre-existing drift items)
  • Single file changed, no secrets, HCL style consistent

Ready for human review.

## Self-Review Reviewed diff against all acceptance criteria from issue #78. No blockers found. **Checklist:** - [x] `*:*:*` grant removed - [x] `autogroup:admin` retains full `*:*` access - [x] `tag:k8s` scoped to inter-node + admin-device only - [x] `group:developers` stub with 443-only access to `tag:k8s` - [x] SSH, nodeAttrs, tagOwners blocks unchanged - [x] `tofu fmt` clean - [x] `tofu validate` passes - [x] `tofu plan` shows only ACL change (plus 2 pre-existing drift items) - [x] Single file changed, no secrets, HCL style consistent Ready for human review.
Author
Owner

Tofu Plan Output

tailscale_acl.this: Refreshing state... [id=acl]
helm_release.nvidia_device_plugin: Refreshing state... [id=nvidia-device-plugin]
data.kubernetes_namespace_v1.tofu_state: Reading...
kubernetes_namespace_v1.monitoring: Refreshing state... [id=monitoring]
kubernetes_namespace_v1.harbor: Refreshing state... [id=harbor]
kubernetes_namespace_v1.woodpecker: Refreshing state... [id=woodpecker]
kubernetes_namespace_v1.minio: Refreshing state... [id=minio]
kubernetes_namespace_v1.postgres: Refreshing state... [id=postgres]
kubernetes_namespace_v1.tailscale: Refreshing state... [id=tailscale]
kubernetes_namespace_v1.forgejo: Refreshing state... [id=forgejo]
data.kubernetes_namespace_v1.tofu_state: Read complete after 0s [id=tofu-state]
kubernetes_namespace_v1.keycloak: Refreshing state... [id=keycloak]
kubernetes_namespace_v1.cnpg_system: Refreshing state... [id=cnpg-system]
data.kubernetes_namespace_v1.pal_e_docs: Reading...
kubernetes_namespace_v1.ollama: Refreshing state... [id=ollama]
kubernetes_service_account_v1.tf_backup: Refreshing state... [id=tofu-state/tf-state-backup]
kubernetes_role_v1.tf_backup: Refreshing state... [id=tofu-state/tf-state-backup]
data.kubernetes_namespace_v1.pal_e_docs: Read complete after 0s [id=pal-e-docs]
kubernetes_secret_v1.dora_exporter: Refreshing state... [id=monitoring/dora-exporter]
helm_release.loki_stack: Refreshing state... [id=loki-stack]
kubernetes_config_map_v1.uptime_dashboard: Refreshing state... [id=monitoring/uptime-dashboard]
kubernetes_service_v1.dora_exporter: Refreshing state... [id=monitoring/dora-exporter]
helm_release.kube_prometheus_stack: Refreshing state... [id=kube-prometheus-stack]
helm_release.forgejo: Refreshing state... [id=forgejo]
helm_release.tailscale_operator: Refreshing state... [id=tailscale-operator]
kubernetes_secret_v1.woodpecker_db_credentials: Refreshing state... [id=woodpecker/woodpecker-db-credentials]
kubernetes_manifest.netpol_monitoring: Refreshing state...
kubernetes_manifest.netpol_postgres: Refreshing state...
kubernetes_manifest.netpol_forgejo: Refreshing state...
kubernetes_manifest.netpol_minio: Refreshing state...
kubernetes_manifest.netpol_woodpecker: Refreshing state...
kubernetes_manifest.netpol_harbor: Refreshing state...
kubernetes_secret_v1.paledocs_db_url: Refreshing state... [id=pal-e-docs/paledocs-db-url]
kubernetes_secret_v1.keycloak_admin: Refreshing state... [id=keycloak/keycloak-admin]
kubernetes_persistent_volume_claim_v1.keycloak_data: Refreshing state... [id=keycloak/keycloak-data]
kubernetes_service_v1.keycloak: Refreshing state... [id=keycloak/keycloak]
kubernetes_manifest.netpol_keycloak: Refreshing state...
kubernetes_role_binding_v1.tf_backup: Refreshing state... [id=tofu-state/tf-state-backup]
helm_release.cnpg: Refreshing state... [id=cnpg]
helm_release.ollama: Refreshing state... [id=ollama]
kubernetes_manifest.netpol_ollama: Refreshing state...
kubernetes_manifest.netpol_cnpg_system: Refreshing state...
kubernetes_deployment_v1.keycloak: Refreshing state... [id=keycloak/keycloak]
kubernetes_config_map_v1.grafana_loki_datasource: Refreshing state... [id=monitoring/grafana-loki-datasource]
helm_release.minio: Refreshing state... [id=minio]
helm_release.harbor: Refreshing state... [id=harbor]
kubernetes_config_map_v1.dora_dashboard: Refreshing state... [id=monitoring/dora-dashboard]
kubernetes_deployment_v1.dora_exporter: Refreshing state... [id=monitoring/dora-exporter]
kubernetes_manifest.blackbox_alerts: Refreshing state...
kubernetes_config_map_v1.pal_e_docs_dashboard: Refreshing state... [id=monitoring/pal-e-docs-dashboard]
helm_release.blackbox_exporter: Refreshing state... [id=blackbox-exporter]
kubernetes_manifest.dora_exporter_service_monitor: Refreshing state...
kubernetes_ingress_v1.forgejo_funnel: Refreshing state... [id=forgejo/forgejo-funnel]
kubernetes_ingress_v1.grafana_funnel: Refreshing state... [id=monitoring/grafana-funnel]
kubernetes_ingress_v1.alertmanager_funnel: Refreshing state... [id=monitoring/alertmanager-funnel]
kubernetes_ingress_v1.keycloak_funnel: Refreshing state... [id=keycloak/keycloak-funnel]
minio_iam_policy.cnpg_wal: Refreshing state... [id=cnpg-wal]
minio_s3_bucket.assets: Refreshing state... [id=assets]
minio_s3_bucket.tf_state_backups: Refreshing state... [id=tf-state-backups]
minio_iam_policy.tf_backup: Refreshing state... [id=tf-backup]
kubernetes_ingress_v1.minio_api_funnel: Refreshing state... [id=minio/minio-api-funnel]
kubernetes_ingress_v1.minio_funnel: Refreshing state... [id=minio/minio-funnel]
minio_s3_bucket.postgres_wal: Refreshing state... [id=postgres-wal]
minio_iam_user.tf_backup: Refreshing state... [id=tf-backup]
minio_iam_user.cnpg: Refreshing state... [id=cnpg]
minio_iam_user_policy_attachment.tf_backup: Refreshing state... [id=tf-backup-20260314163610110100000001]
minio_iam_user_policy_attachment.cnpg: Refreshing state... [id=cnpg-20260302210642491000000001]
kubernetes_secret_v1.tf_backup_s3_creds: Refreshing state... [id=tofu-state/tf-backup-s3-creds]
kubernetes_secret_v1.cnpg_s3_creds: Refreshing state... [id=postgres/cnpg-s3-creds]
kubernetes_secret_v1.woodpecker_cnpg_s3_creds: Refreshing state... [id=woodpecker/cnpg-s3-creds]
kubernetes_cron_job_v1.cnpg_backup_verify: Refreshing state... [id=postgres/cnpg-backup-verify]
kubernetes_cron_job_v1.tf_state_backup: Refreshing state... [id=tofu-state/tf-state-backup]
kubernetes_manifest.woodpecker_postgres: Refreshing state...
helm_release.woodpecker: Refreshing state... [id=woodpecker]
kubernetes_ingress_v1.harbor_funnel: Refreshing state... [id=harbor/harbor-funnel]
kubernetes_ingress_v1.woodpecker_funnel: Refreshing state... [id=woodpecker/woodpecker-funnel]

OpenTofu used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  ~ update in-place

OpenTofu will perform the following actions:

  # tailscale_acl.this will be updated in-place
  ~ resource "tailscale_acl" "this" {
      ~ acl                        = jsonencode(
          ~ {
              ~ grants    = [
                  ~ {
                      ~ src = [
                          - "*",
                          + "autogroup:admin",
                        ]
                        # (2 unchanged attributes hidden)
                    },
                  + {
                      + dst = [
                          + "tag:k8s",
                        ]
                      + ip  = [
                          + "*",
                        ]
                      + src = [
                          + "tag:k8s",
                        ]
                    },
                  + {
                      + dst = [
                          + "autogroup:admin",
                        ]
                      + ip  = [
                          + "*",
                        ]
                      + src = [
                          + "tag:k8s",
                        ]
                    },
                  + {
                      + dst = [
                          + "tag:k8s",
                        ]
                      + ip  = [
                          + "443",
                        ]
                      + src = [
                          + "group:developers",
                        ]
                    },
                ]
              + groups    = {
                  + "group:developers" = []
                }
                # (3 unchanged attributes hidden)
            }
        )
        id                         = "acl"
        # (2 unchanged attributes hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so OpenTofu can't
guarantee to take exactly these actions if you run "tofu apply" now.
## Tofu Plan Output ``` tailscale_acl.this: Refreshing state... [id=acl] helm_release.nvidia_device_plugin: Refreshing state... [id=nvidia-device-plugin] data.kubernetes_namespace_v1.tofu_state: Reading... kubernetes_namespace_v1.monitoring: Refreshing state... [id=monitoring] kubernetes_namespace_v1.harbor: Refreshing state... [id=harbor] kubernetes_namespace_v1.woodpecker: Refreshing state... [id=woodpecker] kubernetes_namespace_v1.minio: Refreshing state... [id=minio] kubernetes_namespace_v1.postgres: Refreshing state... [id=postgres] kubernetes_namespace_v1.tailscale: Refreshing state... [id=tailscale] kubernetes_namespace_v1.forgejo: Refreshing state... [id=forgejo] data.kubernetes_namespace_v1.tofu_state: Read complete after 0s [id=tofu-state] kubernetes_namespace_v1.keycloak: Refreshing state... [id=keycloak] kubernetes_namespace_v1.cnpg_system: Refreshing state... [id=cnpg-system] data.kubernetes_namespace_v1.pal_e_docs: Reading... kubernetes_namespace_v1.ollama: Refreshing state... [id=ollama] kubernetes_service_account_v1.tf_backup: Refreshing state... [id=tofu-state/tf-state-backup] kubernetes_role_v1.tf_backup: Refreshing state... [id=tofu-state/tf-state-backup] data.kubernetes_namespace_v1.pal_e_docs: Read complete after 0s [id=pal-e-docs] kubernetes_secret_v1.dora_exporter: Refreshing state... [id=monitoring/dora-exporter] helm_release.loki_stack: Refreshing state... [id=loki-stack] kubernetes_config_map_v1.uptime_dashboard: Refreshing state... [id=monitoring/uptime-dashboard] kubernetes_service_v1.dora_exporter: Refreshing state... [id=monitoring/dora-exporter] helm_release.kube_prometheus_stack: Refreshing state... [id=kube-prometheus-stack] helm_release.forgejo: Refreshing state... [id=forgejo] helm_release.tailscale_operator: Refreshing state... [id=tailscale-operator] kubernetes_secret_v1.woodpecker_db_credentials: Refreshing state... [id=woodpecker/woodpecker-db-credentials] kubernetes_manifest.netpol_monitoring: Refreshing state... kubernetes_manifest.netpol_postgres: Refreshing state... kubernetes_manifest.netpol_forgejo: Refreshing state... kubernetes_manifest.netpol_minio: Refreshing state... kubernetes_manifest.netpol_woodpecker: Refreshing state... kubernetes_manifest.netpol_harbor: Refreshing state... kubernetes_secret_v1.paledocs_db_url: Refreshing state... [id=pal-e-docs/paledocs-db-url] kubernetes_secret_v1.keycloak_admin: Refreshing state... [id=keycloak/keycloak-admin] kubernetes_persistent_volume_claim_v1.keycloak_data: Refreshing state... [id=keycloak/keycloak-data] kubernetes_service_v1.keycloak: Refreshing state... [id=keycloak/keycloak] kubernetes_manifest.netpol_keycloak: Refreshing state... kubernetes_role_binding_v1.tf_backup: Refreshing state... [id=tofu-state/tf-state-backup] helm_release.cnpg: Refreshing state... [id=cnpg] helm_release.ollama: Refreshing state... [id=ollama] kubernetes_manifest.netpol_ollama: Refreshing state... kubernetes_manifest.netpol_cnpg_system: Refreshing state... kubernetes_deployment_v1.keycloak: Refreshing state... [id=keycloak/keycloak] kubernetes_config_map_v1.grafana_loki_datasource: Refreshing state... [id=monitoring/grafana-loki-datasource] helm_release.minio: Refreshing state... [id=minio] helm_release.harbor: Refreshing state... [id=harbor] kubernetes_config_map_v1.dora_dashboard: Refreshing state... [id=monitoring/dora-dashboard] kubernetes_deployment_v1.dora_exporter: Refreshing state... [id=monitoring/dora-exporter] kubernetes_manifest.blackbox_alerts: Refreshing state... kubernetes_config_map_v1.pal_e_docs_dashboard: Refreshing state... [id=monitoring/pal-e-docs-dashboard] helm_release.blackbox_exporter: Refreshing state... [id=blackbox-exporter] kubernetes_manifest.dora_exporter_service_monitor: Refreshing state... kubernetes_ingress_v1.forgejo_funnel: Refreshing state... [id=forgejo/forgejo-funnel] kubernetes_ingress_v1.grafana_funnel: Refreshing state... [id=monitoring/grafana-funnel] kubernetes_ingress_v1.alertmanager_funnel: Refreshing state... [id=monitoring/alertmanager-funnel] kubernetes_ingress_v1.keycloak_funnel: Refreshing state... [id=keycloak/keycloak-funnel] minio_iam_policy.cnpg_wal: Refreshing state... [id=cnpg-wal] minio_s3_bucket.assets: Refreshing state... [id=assets] minio_s3_bucket.tf_state_backups: Refreshing state... [id=tf-state-backups] minio_iam_policy.tf_backup: Refreshing state... [id=tf-backup] kubernetes_ingress_v1.minio_api_funnel: Refreshing state... [id=minio/minio-api-funnel] kubernetes_ingress_v1.minio_funnel: Refreshing state... [id=minio/minio-funnel] minio_s3_bucket.postgres_wal: Refreshing state... [id=postgres-wal] minio_iam_user.tf_backup: Refreshing state... [id=tf-backup] minio_iam_user.cnpg: Refreshing state... [id=cnpg] minio_iam_user_policy_attachment.tf_backup: Refreshing state... [id=tf-backup-20260314163610110100000001] minio_iam_user_policy_attachment.cnpg: Refreshing state... [id=cnpg-20260302210642491000000001] kubernetes_secret_v1.tf_backup_s3_creds: Refreshing state... [id=tofu-state/tf-backup-s3-creds] kubernetes_secret_v1.cnpg_s3_creds: Refreshing state... [id=postgres/cnpg-s3-creds] kubernetes_secret_v1.woodpecker_cnpg_s3_creds: Refreshing state... [id=woodpecker/cnpg-s3-creds] kubernetes_cron_job_v1.cnpg_backup_verify: Refreshing state... [id=postgres/cnpg-backup-verify] kubernetes_cron_job_v1.tf_state_backup: Refreshing state... [id=tofu-state/tf-state-backup] kubernetes_manifest.woodpecker_postgres: Refreshing state... helm_release.woodpecker: Refreshing state... [id=woodpecker] kubernetes_ingress_v1.harbor_funnel: Refreshing state... [id=harbor/harbor-funnel] kubernetes_ingress_v1.woodpecker_funnel: Refreshing state... [id=woodpecker/woodpecker-funnel] OpenTofu used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: ~ update in-place OpenTofu will perform the following actions: # tailscale_acl.this will be updated in-place ~ resource "tailscale_acl" "this" { ~ acl = jsonencode( ~ { ~ grants = [ ~ { ~ src = [ - "*", + "autogroup:admin", ] # (2 unchanged attributes hidden) }, + { + dst = [ + "tag:k8s", ] + ip = [ + "*", ] + src = [ + "tag:k8s", ] }, + { + dst = [ + "autogroup:admin", ] + ip = [ + "*", ] + src = [ + "tag:k8s", ] }, + { + dst = [ + "tag:k8s", ] + ip = [ + "443", ] + src = [ + "group:developers", ] }, ] + groups = { + "group:developers" = [] } # (3 unchanged attributes hidden) } ) id = "acl" # (2 unchanged attributes hidden) } Plan: 0 to add, 1 to change, 0 to destroy. ───────────────────────────────────────────────────────────────────────────── Note: You didn't use the -out option to save this plan, so OpenTofu can't guarantee to take exactly these actions if you run "tofu apply" now. ```
Author
Owner

PR #79 Review

DOMAIN REVIEW

Tech stack: OpenTofu / Terraform with the Tailscale provider (tailscale_acl resource). Domain checklist: Terraform style, state safety, secrets handling.

ACL grant analysis -- the four scoped grants replacing *:*:*:

# src dst ip Assessment
1 autogroup:admin * * Correct. Admin retains full access.
2 tag:k8s tag:k8s * Correct. Inter-node cluster networking (k3s control plane, kubelet, flannel/CNI). All ports needed.
3 tag:k8s autogroup:admin * Correct. Allows k8s nodes to reach admin devices (webhook callbacks, monitoring push, etc.). All ports appropriate since admin devices are trusted.
4 group:developers tag:k8s 443 Correct. Future-proofing stub. Port-restricted to HTTPS only (Forgejo + Woodpecker web UIs). Group is empty -- no access granted until members are added.

Funnel safety: The nodeAttrs block with funnel attribute is unchanged and operates independently of ACL grants. Funnel traffic enters through the Tailscale node's own proxy, not as a tailnet peer. Public-facing funnels (Grafana, Forgejo, Woodpecker, Harbor, MinIO, Keycloak -- 7 ingresses with tailscale.com/funnel: "true") will continue to work.

SSH safety: The ssh block uses autogroup:member which is orthogonal to grants. Unaffected.

State safety: tofu plan shows in-place update to tailscale_acl.this (no destroy/recreate). The 2 other changes noted in the plan output are pre-existing drift -- correctly called out in the PR body.

Terraform style: tofu fmt and tofu validate pass per PR body. jsonencode() wrapping is consistent with existing pattern. Comments on each grant block are clear and useful. The groups block placement (before grants) follows Tailscale's recommended policy ordering.

Rollback: Tailscale admin console has full ACL history for instant revert. reset_acl_on_destroy = true and overwrite_existing_content = true are both present (unchanged from base). Rollback path is solid.

BLOCKERS

None.

  • No new functionality requiring test coverage (IaC ACL change -- manual verification is the correct test strategy).
  • No user input validation concerns (static ACL policy, no dynamic values).
  • No secrets or credentials in code.
  • No DRY violations -- single ACL resource, no auth logic duplication.

NITS

  1. Consider whether tag:k8s -> autogroup:admin on * is too broad: Currently all ports are open from k8s nodes to admin devices. If the only use case is webhook callbacks, restricting to specific ports (e.g., 443, 8080) would tighten the policy further. However, since admin devices are inherently trusted and use cases may expand, * is a reasonable pragmatic choice. Non-blocking.

  2. group:developers empty stub: The comment says "Future stub -- populate when onboarding non-admin developers." This is good documentation. When developers are eventually added, consider whether port 443 alone is sufficient (e.g., SSH to k8s nodes for debugging would need port 22). But that is future scope -- the stub is correct as-is.

SOP COMPLIANCE

  • Branch named after issue (78-feat-tailscale-acl-tightening-replace-wi -- references issue #78)
  • PR body has: Summary, Changes, Test Plan, Related
  • Related references plan slug (plan-pal-e-platform -- Phase 8b)
  • tofu plan output included (per CLAUDE.md requirement)
  • tofu fmt and tofu validate confirmed passing
  • No secrets committed
  • No unnecessary file changes (1 file, scoped to ACL grants only)
  • Commit message is descriptive (feat: scope Tailscale ACL grants by role instead of *:*:*)
  • Closes #78 in Related section

PROCESS OBSERVATIONS

  • Change failure risk: LOW. The change narrows permissions (defense-in-depth) rather than expanding them. The Tailscale admin console provides instant rollback. The tofu plan output confirms in-place update with no resource recreation.
  • DORA impact: This is Phase 8b of the network security hardening track. Moving from *:*:* to role-scoped grants is a meaningful security posture improvement. Combined with Phase 8a (NetworkPolicies), the platform now has defense-in-depth at both the Tailscale overlay and Kubernetes network layers.
  • Post-apply verification: The unchecked test plan items (admin device access, funnel traffic) should be verified promptly after tofu apply. The safety net (Tailscale ACL history) is documented.

VERDICT: APPROVED

## PR #79 Review ### DOMAIN REVIEW **Tech stack**: OpenTofu / Terraform with the Tailscale provider (`tailscale_acl` resource). Domain checklist: Terraform style, state safety, secrets handling. **ACL grant analysis** -- the four scoped grants replacing `*:*:*`: | # | src | dst | ip | Assessment | |---|-----|-----|----|------------| | 1 | `autogroup:admin` | `*` | `*` | Correct. Admin retains full access. | | 2 | `tag:k8s` | `tag:k8s` | `*` | Correct. Inter-node cluster networking (k3s control plane, kubelet, flannel/CNI). All ports needed. | | 3 | `tag:k8s` | `autogroup:admin` | `*` | Correct. Allows k8s nodes to reach admin devices (webhook callbacks, monitoring push, etc.). All ports appropriate since admin devices are trusted. | | 4 | `group:developers` | `tag:k8s` | `443` | Correct. Future-proofing stub. Port-restricted to HTTPS only (Forgejo + Woodpecker web UIs). Group is empty -- no access granted until members are added. | **Funnel safety**: The `nodeAttrs` block with `funnel` attribute is unchanged and operates independently of ACL grants. Funnel traffic enters through the Tailscale node's own proxy, not as a tailnet peer. Public-facing funnels (Grafana, Forgejo, Woodpecker, Harbor, MinIO, Keycloak -- 7 ingresses with `tailscale.com/funnel: "true"`) will continue to work. **SSH safety**: The `ssh` block uses `autogroup:member` which is orthogonal to grants. Unaffected. **State safety**: `tofu plan` shows in-place update to `tailscale_acl.this` (no destroy/recreate). The 2 other changes noted in the plan output are pre-existing drift -- correctly called out in the PR body. **Terraform style**: `tofu fmt` and `tofu validate` pass per PR body. `jsonencode()` wrapping is consistent with existing pattern. Comments on each grant block are clear and useful. The `groups` block placement (before `grants`) follows Tailscale's recommended policy ordering. **Rollback**: Tailscale admin console has full ACL history for instant revert. `reset_acl_on_destroy = true` and `overwrite_existing_content = true` are both present (unchanged from base). Rollback path is solid. ### BLOCKERS None. - No new functionality requiring test coverage (IaC ACL change -- manual verification is the correct test strategy). - No user input validation concerns (static ACL policy, no dynamic values). - No secrets or credentials in code. - No DRY violations -- single ACL resource, no auth logic duplication. ### NITS 1. **Consider whether `tag:k8s` -> `autogroup:admin` on `*` is too broad**: Currently all ports are open from k8s nodes to admin devices. If the only use case is webhook callbacks, restricting to specific ports (e.g., 443, 8080) would tighten the policy further. However, since admin devices are inherently trusted and use cases may expand, `*` is a reasonable pragmatic choice. Non-blocking. 2. **`group:developers` empty stub**: The comment says "Future stub -- populate when onboarding non-admin developers." This is good documentation. When developers are eventually added, consider whether port 443 alone is sufficient (e.g., SSH to k8s nodes for debugging would need port 22). But that is future scope -- the stub is correct as-is. ### SOP COMPLIANCE - [x] Branch named after issue (`78-feat-tailscale-acl-tightening-replace-wi` -- references issue #78) - [x] PR body has: Summary, Changes, Test Plan, Related - [x] Related references plan slug (`plan-pal-e-platform` -- Phase 8b) - [x] `tofu plan` output included (per CLAUDE.md requirement) - [x] `tofu fmt` and `tofu validate` confirmed passing - [x] No secrets committed - [x] No unnecessary file changes (1 file, scoped to ACL grants only) - [x] Commit message is descriptive (`feat: scope Tailscale ACL grants by role instead of *:*:*`) - [x] `Closes #78` in Related section ### PROCESS OBSERVATIONS - **Change failure risk: LOW**. The change narrows permissions (defense-in-depth) rather than expanding them. The Tailscale admin console provides instant rollback. The `tofu plan` output confirms in-place update with no resource recreation. - **DORA impact**: This is Phase 8b of the network security hardening track. Moving from `*:*:*` to role-scoped grants is a meaningful security posture improvement. Combined with Phase 8a (NetworkPolicies), the platform now has defense-in-depth at both the Tailscale overlay and Kubernetes network layers. - **Post-apply verification**: The unchecked test plan items (admin device access, funnel traffic) should be verified promptly after `tofu apply`. The safety net (Tailscale ACL history) is documented. ### VERDICT: APPROVED
forgejo_admin deleted branch 78-feat-tailscale-acl-tightening-replace-wi 2026-03-15 04:58:16 +00:00
Sign in to join this conversation.
No description provided.