api/v2: add the acl/validate endpoint

It checks a policy as a write would, without storing it. As on
Tailscale, a refused policy answers 200 with its reason; only an empty
body is a 400.
This commit is contained in:
Kristoffer Dalby committed 2026-10-27 21:32:34 +00:00
1 parent a3d9e01eff
commit 52c4f6b2c4
4 files changed
+120 -18

No files matched your search

+69
View File
@@ -1,6 +1,7 @@
package apiv2
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
@@ -12,6 +13,7 @@ import (
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/scope"
"github.com/juanfont/headscale/hscontrol/state"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
)
@@ -42,6 +44,21 @@ type getACLInput struct {
Details bool `doc:"Accepted for compatibility; ignored." query:"details"`
}
type validateACLInput struct {
Tailnet string `path:"tailnet"`
RawBody []byte `contentType:"application/json"`
}
// validateACLOutput is the verdict on a policy. Tailscale answers a refused
// policy with 200, its reason as message and, for failed tests, one data
// entry per failing test.
type validateACLOutput struct {
Body struct {
Message string `json:"message,omitempty"`
Data []apiErrorData `json:"data,omitempty"`
}
}
type setACLInput struct {
Tailnet string `path:"tailnet"`
IfMatch string `header:"If-Match"`
@@ -127,6 +144,58 @@ func registerACL(api huma.API, b Backend) {
return streamPolicy([]byte(updated.Data), aclContentType(in.Accept)), nil
})
registerValidateACL(api, b, aclTags)
}
func registerValidateACL(api huma.API, b Backend, aclTags []string) {
huma.Register(api, requireScope(huma.Operation{
OperationID: "validateACL",
Method: http.MethodPost,
Path: "/api/v2/tailnet/{tailnet}/acl/validate",
Summary: "Validate a policy file",
Description: "Checks a policy, its tests and its SSH rules without storing it. A refused policy answers 200 with the reason as message.",
Tags: aclTags,
Security: security,
DefaultStatus: http.StatusOK,
SkipValidateBody: true,
MaxBodyBytes: 1 << 20,
BodyReadTimeout: 5 * time.Second,
Errors: []int{
http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden,
http.StatusNotFound, http.StatusInternalServerError,
},
}, scope.PolicyFileRead), func(ctx context.Context, in *validateACLInput) (*validateACLOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
// As on Tailscale SaaS, only a body with no policy at all is a 400.
if len(bytes.TrimSpace(in.RawBody)) == 0 {
return nil, huma.Error400BadRequest("unexpected end of JSON input")
}
out := &validateACLOutput{}
err = b.State.ValidatePolicy(in.RawBody)
if errors.Is(err, state.ErrInvalidPolicy) {
refusal := policyRefusal(err)
out.Body.Message, out.Body.Data = refusal.Message, refusal.Data
return out, nil
}
if err != nil {
return nil, mapError("validating policy", err)
}
return out, nil
})
// huma requires a RawBody; the handler answers an empty one as Tailscale
// does.
api.OpenAPI().Paths["/api/v2/tailnet/{tailnet}/acl/validate"].Post.RequestBody.Required = false
}
// currentPolicy returns the stored policy bytes, or the allow-all default when
+1 -1
View File
@@ -89,7 +89,7 @@ func (e *apiError) GetStatus() int { return e.Status }
// policyRefusal answers a refused policy as Tailscale does: the reason in
// message and, when tests failed, one data entry per failing test naming its
// src.
func policyRefusal(err error) error {
func policyRefusal(err error) *apiError {
out := &apiError{Message: err.Error(), Status: http.StatusBadRequest}
var failures []policyv2.TestFailure
+49
View File
@@ -292,6 +292,55 @@ func TestAPIv2ACLRefusalEnvelope(t *testing.T) {
assert.Len(t, tests.Data[1].Errors, 2)
}
// TestAPIv2ACLValidate: validate stores nothing and answers a refused policy
// with 200, its reason as message and one data entry per failing test; only
// a body with no policy at all is a 400.
func TestAPIv2ACLValidate(t *testing.T) {
app := createTestApp(t)
app.state.CreateUserForTest("alice")
api := registerAPIV2(t, app)
type verdict struct {
Message string `json:"message"`
Data []struct {
User string `json:"user"`
Errors []string `json:"errors"`
} `json:"data"`
}
validate := func(body string, wantStatus int) verdict {
t.Helper()
resp := api.Post("/api/v2/tailnet/-/acl/validate",
bytes.NewReader([]byte(body)), "Content-Type: application/hujson")
require.Equalf(t, wantStatus, resp.Code, "body: %s", resp.Body)
var v verdict
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &v))
return v
}
for _, empty := range []string{"", " \n\t"} {
assert.Equal(t, "unexpected end of JSON input",
validate(empty, http.StatusBadRequest).Message)
}
assert.Equal(t, verdict{}, validate(allowAllPolicy, http.StatusOK))
assert.NotEmpty(t, validate(`{"acls": [`, http.StatusOK).Message)
failed := validate(`{
"acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}],
"tests": [{"src": "alice@", "deny": ["100.64.0.2:22"]}]
}`, http.StatusOK)
assert.Equal(t, "test(s) failed", failed.Message)
require.Len(t, failed.Data, 1)
assert.Equal(t, "alice", failed.Data[0].User)
_, err := app.state.GetPolicy()
require.ErrorIs(t, err, types.ErrPolicyNotFound, "validate stores nothing")
}
func TestAPIv2ACLNonDefaultTailnet404(t *testing.T) {
api := registerAPIV2(t, createTestApp(t))
+1 -17
View File
@@ -74,18 +74,6 @@ var apiv2CompatSkipReasons = map[string]string{
"oauth/device:before/all/device-oauth-client": deviceOAuthClientMaker,
"oauth/device:joiner/all/device-oauth-client": deviceOAuthClientMaker,
"policy-api/P2/0/status": noV2Validate,
"policy-api/P2/0/message": noV2Validate,
"policy-api/P3/0/status": noV2Validate,
"policy-api/P3/0/message": noV2Validate,
"policy-api/P4/0/status": noV2Validate,
"policy-api/P4/0/outcome": noV2Validate,
"policy-api/P5-R1/0/status": noV2Validate,
"policy-api/P5-R1/0/message": noV2Validate,
"policy-api/P5-R2/0/status": noV2Validate,
"policy-api/P5-R2/0/message": noV2Validate,
"policy-api/P5-R2/0/detail": noV2Validate,
"policy-api/P7/0/body": hujsonFormatted,
"policy-api/P7/1/body": hujsonFormatted,
}
@@ -127,7 +115,6 @@ const (
deletedClientGone = "DELETED_CLIENT_GONE: SaaS keeps a deleted OAuth client readable as invalid and revoked; headscale removes it"
deletedClientCredentialText = "DELETED_CLIENT_CREDENTIAL_TEXT: SaaS refuses a deleted client's secret and tokens with API token invalid; headscale says invalid client credentials and unauthorized"
missingScopeText = "MISSING_SCOPE_TEXT: SaaS refuses a delete outside the token's scopes with calling actor does not have enough permissions; headscale names the missing scope"
noV2Validate = "NO_V2_VALIDATE: headscale's v2 API has no /acl/validate"
hujsonFormatted = "HUJSON_FORMATTED: SaaS stores a HuJSON policy formatted, comments kept; headscale stores the bytes as sent"
)
@@ -1144,10 +1131,7 @@ func (r *apiReplay) refusalChecks(t *testing.T, a testcapture.Action, endpoint s
require.NoError(t, json.Unmarshal(a.Response, &want))
// Headscale has no v2 validate, so only there may its answer not be JSON.
if err := json.Unmarshal(resp, &got); endpoint != "/tailnet/-/acl/validate" {
require.NoErrorf(t, err, "body: %s", resp)
}
require.NoErrorf(t, json.Unmarshal(resp, &got), "body: %s", resp)
users := func(data []struct {
User string `json:"user"`