Recipe 05 · advanced · 9 min

Put your access policy in git and let tests block the bad merge

A policy change that would lock you out of your own network fails a check instead of taking effect.

Position in the recipes track 01 Make ev. 02 Stop ty. 03 Receive. 04 Replace. 05 Put you. 06 Give a . 07 Put a t.

What you get

The tailnet policy file living in a git repository, changing only through pull requests, with an automated check that reads every proposed version, validates it, and runs your access assertions against it before anything reaches the tailnet. Reviewers read a diff with reasons attached instead of squinting at a text box. A change that would silently remove access to something important turns the check red and never merges.

The reason to do this is not tidiness. The policy file is the one piece of configuration in your network where a single careless edit can remove your own ability to undo the edit. Delete the grant that lets your operators reach the management hosts, and the path you would use to restore the grant is the path you just deleted. Every other misconfiguration in this guide is recoverable by walking over to a machine. This one is recoverable by opening a support ticket.

Tests in the policy file are the specific answer to that specific failure. They are assertions that run every time the policy changes, and a failed assertion causes Tailscale to reject the new policy file with an error. That is the whole idea: you cannot delete the door you came in through, because a test is standing in front of it.

How it works

Three mechanisms stack, and each one is doing a distinct job.

The format is HuJSON, human JSON, which permits comments and trailing commas. That is not cosmetic. Comments let a rule carry the reason it exists on the line directly above it, which is the single highest value thing a reviewer can be given. Trailing commas mean adding an entry to a list produces a one line diff instead of a two line diff that also touches the previous line to add a comma. Review quality is a function of diff noise, and HuJSON removes a whole category of it.

The assertions are the tests and sshTests sections inside the policy file itself. They are not a separate test suite in a separate language. They travel with the thing they describe, in the same file, in the same commit, so the rule and the proof of the rule can never drift apart.

The pipeline is the official GitOps action, tailscale/gitops-acl-action. On a pull request it runs with action: test, which sends the proposed policy file to Tailscale to determine whether the policy is valid and whether all tests pass. On a push to the main branch it runs with action: apply, which validates and tests again, and only then updates the tailnet.

Where the policy change is stopped, and by whom policy.hujson on a branch pull request human review action: test uploads the file Tailscale runs the assertions an assertion failed no merge, tailnet unchanged all assertions passed merge to main action: apply validates again, then ships

the gate is on Tailscale’s side, not in your runner. the runner only carries the file and reports the verdict.

Build it

  1. Move the current policy into a repository, unchanged. Copy the contents of the Access controls page in the admin console into a file named policy.hujson at the repository root, commit it, and change nothing else in the first commit. That commit is your baseline. If the pipeline later disagrees with the tailnet, you want the disagreement to be about a change you made, not about a transcription error.

  2. Mint the credential the pipeline will use. Create an OAuth client and give it the policy_file scope, which permits reading, validating, and modifying the policy file. If you want a credential that can only run the pull request check and can never ship, use policy_file:read, which permits reading and validating. OAuth access tokens are issued from the client ID and secret and expire after one hour, so the pipeline mints a fresh one on every run and there is nothing long lived sitting in a runner.

  3. Store the secrets under the names the action expects: TS_TAILNET for the tailnet name, plus TS_OAUTH_ID and TS_OAUTH_SECRET for the client. Keep them repository secrets. A credential with policy_file scope can rewrite who reaches what in your entire network.

  4. Add the workflow. Two steps, one guarded to pull requests and one guarded to pushes.

    name: Sync Tailscale ACLs
    
    on:
      push:
        branches: [ "main" ]
      pull_request:
        branches: [ "main" ]
    
    jobs:
      acls:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v6
    
          - name: Deploy ACL
            if: github.event_name == 'push'
            id: deploy-acl
            uses: tailscale/gitops-acl-action@v1
            with:
              oauth-client-id: ${{ secrets.TS_OAUTH_ID }}
              oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
              tailnet: ${{ secrets.TS_TAILNET }}
              action: apply
    
          - name: Test ACL
            if: github.event_name == 'pull_request'
            id: test-acl
            uses: tailscale/gitops-acl-action@v1
            with:
              oauth-client-id: ${{ secrets.TS_OAUTH_ID }}
              oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
              tailnet: ${{ secrets.TS_TAILNET }}
              action: test

    The action reads policy.hujson from the repository root by default. If you keep the file somewhere else, pass the policy-file input.

  5. Write the assertions that name what you cannot lose. A test runs from the perspective of a device authenticated as the given identity. Destinations are written as host:port with a single numeric port. proto is optional and, when omitted, the check covers TCP or UDP access.

    "tests": [
      {
        "src": "group:operators",
        "proto": "tcp",
        "accept": ["lab-vm-1:22", "cloud-1:22"],
        "deny":   ["node-b:5432"],
      },
    ],
  6. Write the lockout assertion first and treat it as untouchable. This is the whole point of the exercise. Name the identity that repairs the network and the hosts it repairs the network from, and assert that path in both directions of the argument: the repair path is accepted, and the paths that must never open are denied. Put a HuJSON comment above it saying, in words, that removing this test is itself the outage.

    // Removing this test is the incident. The operators group must keep
    // a path to the management hosts, because that path is how any bad
    // policy merge gets reverted.
    {
      "src": "group:operators",
      "accept": ["lab-vm-1:22"],
    },
  7. Cover SSH separately, because ACL tests do not cover it. sshTests asserts on the SSH user that an identity may become, with accept for users allowed outright, check for users that require a further authentication check, and deny for users never permitted.

    "sshTests": [
      {
        "src": "group:operators",
        "dst": ["lab-vm-1"],
        "accept": ["ops"],
        "check":  ["admin"],
        "deny":   ["root"],
      },
    ],
  8. Make the check mandatory in the forge, not merely present. Require the test check to pass and require at least one review before merge. Without branch protection the action is a suggestion. With it, the action is a gate.

  9. Give reviewers a local dry run. The API validates a proposed policy without changing anything, at POST /api/v2/tailnet/{tailnet}/acl/validate. Send a policy object and it validates the syntax and runs the tests included in it, or send an array of test objects and it runs those against the current policy.

    curl "https://api.tailscale.com/api/v2/tailnet/example.com/acl/validate" \
      -u "${TS_API_TOKEN}:" \
      -H "Content-Type: application/json" \
      --data-binary '[{"src": "group:operators", "accept": ["lab-vm-1:22"]}]'

Verify it

  1. Run a negative control before you trust a green result. Open a pull request that deliberately deletes the grant your lockout test depends on. The check must go red and must name the failing assertion. A pipeline that has never been observed failing is not a pipeline, it is decoration.

  2. Run the positive control. Merge a change that only adds a HuJSON comment, then confirm the admin console shows that comment. That proves the apply step has the write scope and is actually reaching your tailnet, which the test step alone never proves.

  3. Prove which side is authoritative. Make a small edit directly in the admin console, then merge any change from the repository. The apply step ships the repository version, so your console edit disappears. Watch that happen once, deliberately, on a change you do not care about, so nobody discovers it during an incident.

  4. Know what failure looks like. The validate endpoint returns a 200 status even when tests fail, and the outcome is in the response body. Any wrapper you build yourself must read the body rather than the status code, or it will report success on every broken policy you ever write.

Gotchas

  1. The disaster this prevents has a specific shape. Someone tightens a rule, the change looks correct in review, and it removes access to the exact hosts an operator would need in order to revert it. There is no local console, no out of band path, and the person who can fix it in the admin console is on a plane. A single test naming that path costs one commit and prevents that entire evening.

  2. Tests only assert what you wrote down. They cannot infer intent. Treat coverage of the repair path, the monitoring path, and the backup path as mandatory and everything else as nice to have.

  3. The admin console stays writable. Adopting GitOps does not lock the web form, so drift is possible in the window between a console edit and the next apply. If you script your own updates instead of using the action, the API supports an If-Match header carrying the ETag from a prior read, so a concurrent change causes a rejection instead of a silent overwrite.

  4. API keys expire in ninety days. The action documentation is explicit that if you use an API key you should schedule a monthly rotation, or use a trust credential such as an OAuth client or federated identity instead. A pipeline that fails closed on an expired key on a Friday afternoon is a self inflicted wound.

  5. Pick the narrower scope where you can. policy_file:read can validate but never modify. If your review workflow runs in a less trusted context than your deploy workflow, that difference is worth the extra client.

  6. Asking the API for JSON costs you your comments. A read returns HuJSON by default and returns plain JSON when you ask for application/json. Comments do not survive the JSON representation. This is another reason the repository, not a round trip through the API, is the source of truth.

  7. Assertions reference identities that must exist. A test naming a group that your identity provider no longer syncs will fail, and it will fail on whatever pull request happens to be open at the time. That is correct behavior with confusing timing.

Where to take it next

  1. Add posture conditions to your assertions with srcPostureAttrs, so the tests describe not only who may reach a host but from what kind of device, and a posture rule change cannot quietly widen a path.
  2. Use the preview endpoint to generate review evidence automatically: post a comment on each pull request listing what a named identity can reach under the proposed policy, so reviewers compare outcomes instead of reading diffs of rules.
  3. Mirror the same pipeline in whichever forge you actually use. The identical workflow exists for GitLab CI and Bitbucket, and the credential model is the same, so there is no reason to hand roll a script.
  4. Extend the pattern to the rest of your tailnet configuration. Once the access policy is reviewable, the fact that tags, auth keys, and device approvals still change by hand starts to look like the anomaly it is.

Sources

  1. Syntax reference for the tailnet policy file checked 2026-08-11
  2. Tailnet policy file syntax checked 2026-08-11
  3. GitOps for Tailscale checked 2026-08-11
  4. GitOps for Tailscale with GitHub Actions checked 2026-08-11
  5. tailscale/gitops-acl-action checked 2026-08-11
  6. Edit access control policies in your tailnet policy file checked 2026-08-11
  7. Tailscale API, tailnet endpoints checked 2026-08-11
  8. OAuth clients checked 2026-08-11

All recipes